diff --git a/.github/workflows/turborepo-release.yml b/.github/workflows/turborepo-release.yml index 86a7e3f43c3b5..358ac49289845 100644 --- a/.github/workflows/turborepo-release.yml +++ b/.github/workflows/turborepo-release.yml @@ -417,6 +417,11 @@ jobs: if: ${{ matrix.settings.setup }} run: ${{ matrix.settings.setup }} + - name: Test Windows standalone installer + if: runner.os == 'Windows' + shell: powershell + run: ./scripts/install.windows.test.ps1 + - name: Build run: ${{ matrix.settings.rust-build-env }} cargo build --profile release-turborepo -p turbo --target ${{ matrix.settings.target }} @@ -543,8 +548,8 @@ jobs: pattern: turbo-* path: rust-artifacts - - name: Test standalone archive layout - run: node --test scripts/package-standalone-archives.test.mjs + - name: Test standalone archive layout and POSIX installer + run: node --test scripts/package-standalone-archives.test.mjs scripts/install.test.mjs - name: Package standalone archives run: node scripts/package-standalone-archives.mjs "${{ needs.stage.outputs.version }}" @@ -553,7 +558,9 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: standalone-turbo-${{ needs.stage.outputs.version }} - path: standalone-artifacts/*.tar.gz + path: | + standalone-artifacts/*.tar.gz + standalone-artifacts/SHA256SUMS if-no-files-found: error npm-publish: @@ -701,10 +708,43 @@ jobs: gh release create "v${VERSION}" \ --title "Turborepo v${VERSION}" \ --generate-notes \ + --draft \ "${NOTES_START_TAG_FLAG[@]}" \ "${PRERELEASE_FLAG[@]}" fi + publish-standalone-assets: + name: "Publish Standalone Release Assets" + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + needs: [stage, standalone-archives, create-release-tag] + if: ${{ always() && !inputs.dry_run && needs.stage.result == 'success' && needs.standalone-archives.result == 'success' && needs.create-release-tag.result == 'success' }} + steps: + - name: Download standalone release assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: standalone-turbo-${{ needs.stage.outputs.version }} + path: standalone-artifacts + + - name: Attach standalone archives and checksum manifest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.stage.outputs.version }} + run: | + if [ "$(gh release view "v${VERSION}" --json isDraft --jq .isDraft)" != "true" ]; then + echo "::error::Refusing to replace assets on published release v${VERSION}." + exit 1 + fi + gh release upload "v${VERSION}" standalone-artifacts/*.tar.gz standalone-artifacts/SHA256SUMS --clobber + + - name: Publish GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.stage.outputs.version }} + run: gh release edit "v${VERSION}" --draft=false + publish-vscode-extension: name: "Publish VS Code Extension" needs: [stage, js-smoke-test, create-release-tag] @@ -819,8 +859,15 @@ jobs: create-release-pr: name: "Create Release PR" - needs: [stage, npm-publish, create-release-tag, alias-versioned-docs] - if: ${{ always() && needs.npm-publish.result == 'success' && needs.create-release-tag.result == 'success' && !inputs.dry_run }} + needs: + [ + stage, + npm-publish, + create-release-tag, + publish-standalone-assets, + alias-versioned-docs + ] + if: ${{ always() && needs.npm-publish.result == 'success' && needs.create-release-tag.result == 'success' && needs.publish-standalone-assets.result == 'success' && !inputs.dry_run }} runs-on: ubuntu-24.04 timeout-minutes: 30 permissions: diff --git a/apps/docs/next.config.ts b/apps/docs/next.config.ts index ab79ac5111680..26ee029c90532 100644 --- a/apps/docs/next.config.ts +++ b/apps/docs/next.config.ts @@ -12,6 +12,19 @@ const config: NextConfig = { experimental: { turbopackFileSystemCacheForDev: true }, + async headers() { + const installerHeaders = [ + { key: "Content-Type", value: "text/plain; charset=utf-8" }, + { + key: "Cache-Control", + value: "public, max-age=0, must-revalidate" + } + ]; + return [ + { source: "/install", headers: installerHeaders }, + { source: "/install.ps1", headers: installerHeaders } + ]; + }, typescript: { ignoreBuildErrors: true }, diff --git a/apps/docs/proxy.ts b/apps/docs/proxy.ts index f96dbabd970eb..906c88e499081 100644 --- a/apps/docs/proxy.ts +++ b/apps/docs/proxy.ts @@ -16,7 +16,7 @@ const proxy = createProxy({ export const config = { matcher: [ - "/((?!api(?:/|$)|_next/static|_next/image|favicon.ico|feed.xml|sitemap.xml|robots.txt|images(?:/|$)|og-image\\.png|schema\\.json|schema\\.v\\d+\\.json|microfrontends/schema\\.json|\\.well-known/security\\.txt).*)" + "/((?!api(?:/|$)|_next/static|_next/image|favicon.ico|feed.xml|sitemap.xml|robots.txt|images(?:/|$)|og-image\\.png|schema\\.json|schema\\.v\\d+\\.json|install(?:/|$)|install[.]ps1(?:/|$)|microfrontends/schema\\.json|\\.well-known/security\\.txt).*)" ] }; diff --git a/apps/docs/public/install b/apps/docs/public/install new file mode 100644 index 0000000000000..9e1bd52926adf --- /dev/null +++ b/apps/docs/public/install @@ -0,0 +1,249 @@ +#!/bin/sh +set -eu + +usage() { + cat <<'EOF' +Install the standalone Turborepo CLI without Node.js or a JavaScript package manager. + +Usage: install [--version VERSION] [--install-dir DIRECTORY] + +By default, the installer uses the latest stable release and installs to +~/.local/bin. Set TURBO_VERSION or TURBO_INSTALL_DIR to configure this in CI. +EOF +} + +fail() { + printf '%s\n' "$1" >&2 + exit 1 +} + +version=${TURBO_VERSION:-} +install_dir=${TURBO_INSTALL_DIR:-} +while [ "$#" -gt 0 ]; do + case "$1" in + --version) + [ "$#" -ge 2 ] || fail "--version requires a value" + version=$2 + shift 2 + ;; + --install-dir) + [ "$#" -ge 2 ] || fail "--install-dir requires a value" + install_dir=$2 + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + --) + shift + break + ;; + *) + fail "unknown option: $1" + ;; + esac +done +[ "$#" -eq 0 ] || fail "unexpected argument: $1" + +if existing_turbo=$(command -v turbo 2>/dev/null); then + fail "An existing \"turbo\" alias was found on PATH at $existing_turbo. Uninstall it and then retry installing the new version again." +fi + +[ -n "${HOME:-}" ] || fail 'HOME is not set' +[ -n "$install_dir" ] || install_dir="$HOME/.local/bin" +case "$install_dir" in + /*) ;; + *) fail "install directory must be an absolute path: $install_dir" ;; +esac +newline=$(printf '\n_') +newline=${newline%_} +carriage_return=$(printf '\r') +case "$install_dir" in + *"$newline"*|*"$carriage_return"*|*:*) + fail 'install directory must not contain a colon or line break' + ;; +esac + +os=$(uname -s) +architecture=$(uname -m) +case "$os:$architecture" in + Darwin:x86_64) target=x86_64-apple-darwin ;; + Darwin:arm64|Darwin:aarch64) target=aarch64-apple-darwin ;; + Linux:x86_64|Linux:amd64) target=x86_64-unknown-linux-musl ;; + Linux:arm64|Linux:aarch64) target=aarch64-unknown-linux-musl ;; + *) fail "unsupported platform: $os/$architecture" ;; +esac + +binary_name=turbo +destination="$install_dir/$binary_name" +if [ -e "$destination" ] || [ -L "$destination" ]; then + fail "$destination already exists; it was left untouched. Remove it yourself or choose another --install-dir." +fi + +for utility in curl tar awk grep sed head mktemp mkdir cp chmod ln rm tr; do + command -v "$utility" >/dev/null 2>&1 || fail "required command not found: $utility" +done + +work_dir=$(mktemp -d "${TMPDIR:-/tmp}/turbo-install.XXXXXX") || fail 'could not create a temporary directory' +staged_binary= +cleanup() { + if [ -n "$staged_binary" ]; then + rm -f "$staged_binary" + fi + rm -rf "$work_dir" +} +trap cleanup 0 +trap 'exit 130' INT +trap 'exit 143' TERM + +shell_quote() { + quoted=$(printf '%s' "$1" | sed "s/'/'\\\\''/g") + printf "'%s'" "$quoted" +} + +append_path_profile() { + profile=$1 + marker="# Turborepo standalone installer PATH: $install_dir" + if [ -f "$profile" ] && grep -Fqx "$marker" "$profile"; then + return 0 + fi + + quoted_install_dir=$(shell_quote "$install_dir") + if ! { + printf '\n%s\n' "$marker" + printf 'case ":$PATH:" in\n' + printf ' *:%s:*) ;;\n' "$quoted_install_dir" + printf ' *) PATH=%s:"$PATH" ;;\n' "$quoted_install_dir" + printf 'esac\nexport PATH\n' + } >> "$profile"; then + return 1 + fi +} + +download() { + curl --fail --show-error --silent --location \ + --proto '=https' --proto-redir '=https' "$@" +} + +if [ -z "$version" ] || [ "$version" = latest ]; then + metadata="$work_dir/release.json" + if ! download --output "$metadata" \ + --header 'Accept: application/vnd.github+json' \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + 'https://api.github.com/repos/vercel/turborepo/releases/latest'; then + fail 'could not resolve the latest stable release from GitHub' + fi + version=$(sed -nE 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v?([^"]+)".*/\1/p' "$metadata" | head -n 1) +fi + +case "$version" in + v*) version=${version#v} ;; +esac +if ! printf '%s\n' "$version" | LC_ALL=C grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; then + fail "invalid release version: ${version:-}" +fi + +archive_name="turbo-$version-$target.tar.gz" +release_url="https://github.com/vercel/turborepo/releases/download/v$version" +manifest="$work_dir/SHA256SUMS" +archive="$work_dir/$archive_name" +if ! download --output "$manifest" "$release_url/SHA256SUMS"; then + fail "could not download the checksum manifest for v$version" +fi +if ! download --output "$archive" "$release_url/$archive_name"; then + fail "could not download the standalone archive for $target at v$version" +fi + +expected_checksum=$(awk -v filename="$archive_name" ' + $2 == filename { + count++ + if (NF == 2) digest = $1 + else digest = "" + } + END { + if (count == 1) print digest + } +' "$manifest") +if ! printf '%s\n' "$expected_checksum" | LC_ALL=C grep -Eq '^[[:xdigit:]]{64}$'; then + fail "checksum manifest must contain exactly one SHA-256 entry for $archive_name" +fi +if command -v sha256sum >/dev/null 2>&1; then + actual_checksum=$(sha256sum "$archive" | awk '{print $1}') +elif command -v shasum >/dev/null 2>&1; then + actual_checksum=$(shasum -a 256 "$archive" | awk '{print $1}') +else + fail 'a SHA-256 utility (sha256sum or shasum) is required' +fi +expected_checksum=$(printf '%s' "$expected_checksum" | tr '[:upper:]' '[:lower:]') +actual_checksum=$(printf '%s' "$actual_checksum" | tr '[:upper:]' '[:lower:]') +[ "$actual_checksum" = "$expected_checksum" ] || fail "SHA-256 verification failed for $archive_name" + +members=$(tar -tzf "$archive") || fail "could not inspect $archive_name" +[ "$members" = "$binary_name" ] || fail "unexpected archive contents in $archive_name; expected only $binary_name" +extracted_dir="$work_dir/extracted" +mkdir "$extracted_dir" +tar -xzf "$archive" -C "$extracted_dir" || fail "could not extract $archive_name" +extracted_binary="$extracted_dir/$binary_name" +[ -f "$extracted_binary" ] && [ ! -L "$extracted_binary" ] || fail 'archive did not contain a regular turbo executable' +chmod 0755 "$extracted_binary" + +mkdir -p "$install_dir" || fail "could not create install directory: $install_dir" +if [ -e "$destination" ] || [ -L "$destination" ]; then + fail "$destination already exists; it was left untouched. Remove it yourself or choose another --install-dir." +fi +staged_binary=$(mktemp "$install_dir/.turbo-install.XXXXXX") || fail "could not create a staging file in $install_dir" +if ! cp "$extracted_binary" "$staged_binary" || ! chmod 0755 "$staged_binary"; then + fail "could not stage turbo in $install_dir" +fi +if ! ln "$staged_binary" "$destination"; then + fail "could not install $destination without replacing an existing file" +fi +rm -f "$staged_binary" +staged_binary= + +configure_path() { + if [ -n "${GITHUB_PATH:-}" ]; then + if ! grep -Fqx "$install_dir" "$GITHUB_PATH"; then + printf '%s\n' "$install_dir" >> "$GITHUB_PATH" || return 1 + fi + return 0 + fi + [ "${TURBO_NO_MODIFY_PATH:-0}" != 1 ] || return 1 + + shell_name=${SHELL:-} + shell_name=${shell_name##*/} + case "$shell_name" in + zsh) + append_path_profile "$HOME/.zprofile" || return 1 + append_path_profile "$HOME/.zshrc" || return 1 + ;; + bash) + if [ -f "$HOME/.bash_profile" ]; then + login_profile="$HOME/.bash_profile" + elif [ -f "$HOME/.bash_login" ]; then + login_profile="$HOME/.bash_login" + else + login_profile="$HOME/.profile" + fi + append_path_profile "$login_profile" || return 1 + append_path_profile "$HOME/.bashrc" || return 1 + ;; + fish) + return 1 + ;; + *) + append_path_profile "$HOME/.profile" || return 1 + ;; + esac +} + +printf 'Installed turbo %s at %s\n' "$version" "$destination" +if configure_path; then + case ":${PATH:-}:" in + *":$install_dir:"*) ;; + *) printf 'PATH configured for future shells; open a new shell to run `turbo` by name.\n' ;; + esac +else + printf 'Add %s to PATH to run `turbo` by name.\n' "$install_dir" +fi diff --git a/apps/docs/public/install.ps1 b/apps/docs/public/install.ps1 new file mode 100644 index 0000000000000..dc8473ae18967 --- /dev/null +++ b/apps/docs/public/install.ps1 @@ -0,0 +1,246 @@ +[CmdletBinding()] +param( + [Alias('Version')] + [string]$TurboInstallerVersion, + [Alias('InstallDirectory')] + [string]$TurboInstallerInstallDirectory, + [Alias('NoModifyPath')] + [switch]$TurboInstallerNoModifyPath +) + +$installerArguments = @{ + Version = if ($PSBoundParameters.ContainsKey('TurboInstallerVersion')) { $TurboInstallerVersion } else { $env:TURBO_VERSION } + InstallDirectory = if ($PSBoundParameters.ContainsKey('TurboInstallerInstallDirectory')) { $TurboInstallerInstallDirectory } else { $env:TURBO_INSTALL_DIR } + NoModifyPath = $PSBoundParameters.ContainsKey('TurboInstallerNoModifyPath') -and $TurboInstallerNoModifyPath +} + +function Invoke-TurboInstaller { + [CmdletBinding()] + param( + [string]$Version, + [string]$InstallDirectory, + [switch]$NoModifyPath + ) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +try { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 +} catch { + # Newer PowerShell versions do not require a ServicePointManager override. +} + +function Get-TurboVersion { + param([string]$RequestedVersion) + + if ([string]::IsNullOrWhiteSpace($RequestedVersion) -or $RequestedVersion -eq 'latest') { + $response = Invoke-WebRequest ` + -Uri 'https://api.github.com/repos/vercel/turborepo/releases/latest' ` + -Headers @{ Accept = 'application/vnd.github+json'; 'X-GitHub-Api-Version' = '2022-11-28' } ` + -UseBasicParsing ` + -ErrorAction Stop + $release = $response.Content | ConvertFrom-Json -ErrorAction Stop + $RequestedVersion = [string]$release.tag_name + } + + if ($RequestedVersion.StartsWith('v', [StringComparison]::OrdinalIgnoreCase)) { + $RequestedVersion = $RequestedVersion.Substring(1) + } + if ($RequestedVersion -notmatch '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$') { + throw "Invalid Turborepo release version: $RequestedVersion" + } + return $RequestedVersion +} + +function Add-TurboToUserPath { + param([string]$Directory) + + $normalizedDirectory = [IO.Path]::GetFullPath($Directory).TrimEnd([char[]]@('\', '/')) + if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_PATH)) { + $normalizedGitHubDirectory = [IO.Path]::GetFullPath($Directory).TrimEnd([IO.Path]::DirectorySeparatorChar) + $githubPathEntries = @() + if (Test-Path -LiteralPath $env:GITHUB_PATH) { + $githubPathEntries = [IO.File]::ReadAllLines($env:GITHUB_PATH) + } + $alreadyInGitHubPath = $false + foreach ($entry in $githubPathEntries) { + if ([string]::Equals($entry.TrimEnd([IO.Path]::DirectorySeparatorChar), $normalizedGitHubDirectory, [StringComparison]::OrdinalIgnoreCase)) { + $alreadyInGitHubPath = $true + break + } + } + if (-not $alreadyInGitHubPath) { + [IO.File]::AppendAllText($env:GITHUB_PATH, $Directory + [Environment]::NewLine, [Text.UTF8Encoding]::new($false)) + } + + $githubProcessEntries = @($env:Path -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + $alreadyInGitHubProcessPath = $false + foreach ($entry in $githubProcessEntries) { + if ([string]::Equals($entry.TrimEnd([IO.Path]::DirectorySeparatorChar), $normalizedGitHubDirectory, [StringComparison]::OrdinalIgnoreCase)) { + $alreadyInGitHubProcessPath = $true + break + } + } + if (-not $alreadyInGitHubProcessPath) { + $env:Path = if ([string]::IsNullOrWhiteSpace($env:Path)) { $Directory } else { "$Directory;$env:Path" } + } + return + } + + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $userEntries = @($userPath -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + $alreadyInUserPath = $false + foreach ($entry in $userEntries) { + if ([string]::Equals($entry.TrimEnd([char[]]@('\', '/')), $normalizedDirectory, [StringComparison]::OrdinalIgnoreCase)) { + $alreadyInUserPath = $true + break + } + } + if (-not $alreadyInUserPath) { + $newUserPath = if ([string]::IsNullOrWhiteSpace($userPath)) { $Directory } else { "$userPath;$Directory" } + [Environment]::SetEnvironmentVariable('Path', $newUserPath, 'User') + } + + $processEntries = @($env:Path -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + $alreadyInProcessPath = $false + foreach ($entry in $processEntries) { + if ([string]::Equals($entry.TrimEnd([char[]]@('\', '/')), $normalizedDirectory, [StringComparison]::OrdinalIgnoreCase)) { + $alreadyInProcessPath = $true + break + } + } + if (-not $alreadyInProcessPath) { + $env:Path = if ([string]::IsNullOrWhiteSpace($env:Path)) { $Directory } else { "$Directory;$env:Path" } + } +} + +$existingTurbo = Get-Command -Name 'turbo' -All -ErrorAction SilentlyContinue | + Where-Object { $_.CommandType -in @('Application', 'ExternalScript', 'Script') } | + Select-Object -First 1 +if ($null -ne $existingTurbo) { + $existingTurboPath = $existingTurbo.Source + if ([string]::IsNullOrWhiteSpace($existingTurboPath)) { + $existingTurboPath = $existingTurbo.Path + } + if ([string]::IsNullOrWhiteSpace($existingTurboPath)) { + $existingTurboPath = $existingTurbo.Definition + } + throw ('An existing "turbo" alias was found on PATH at {0}. Uninstall it and then retry installing the new version again.' -f $existingTurboPath) +} + +if ([string]::IsNullOrWhiteSpace($InstallDirectory)) { + if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + throw 'LOCALAPPDATA is not set; provide -InstallDirectory or TURBO_INSTALL_DIR.' + } + $InstallDirectory = Join-Path $env:LOCALAPPDATA 'Programs\Turborepo\bin' +} +$InstallDirectory = [IO.Path]::GetFullPath($InstallDirectory) +if ($InstallDirectory.IndexOfAny([char[]]@(';', "`r", "`n")) -ge 0) { + throw 'InstallDirectory must not contain a semicolon or line break.' +} +$destination = Join-Path $InstallDirectory 'turbo.exe' +if (Test-Path -LiteralPath $destination) { + throw "$destination already exists; it was left untouched. Remove it yourself or choose another install directory." +} + +$architecture = $env:PROCESSOR_ARCHITECTURE +if (-not [string]::IsNullOrWhiteSpace($env:PROCESSOR_ARCHITEW6432)) { + $architecture = $env:PROCESSOR_ARCHITEW6432 +} +if ($architecture -notin @('AMD64', 'x86_64')) { + throw "Unsupported Windows architecture: $architecture. The standalone installer currently supports Windows x64 only." +} + +$tarCommand = Get-Command tar.exe -ErrorAction SilentlyContinue +if ($null -eq $tarCommand) { + throw 'tar.exe is required to extract the standalone archive. Use a supported Windows 10 or Windows 11 installation.' +} + +$workDirectory = Join-Path ([IO.Path]::GetTempPath()) ("turbo-install-" + [Guid]::NewGuid().ToString('N')) +$stagedBinary = $null +try { + [IO.Directory]::CreateDirectory($workDirectory) | Out-Null + $Version = Get-TurboVersion -RequestedVersion $Version + $target = 'x86_64-pc-windows-msvc' + $archiveName = "turbo-$Version-$target.tar.gz" + $releaseUrl = "https://github.com/vercel/turborepo/releases/download/v$Version" + $manifestPath = Join-Path $workDirectory 'SHA256SUMS' + $archivePath = Join-Path $workDirectory $archiveName + + Invoke-WebRequest -Uri "$releaseUrl/SHA256SUMS" -OutFile $manifestPath -UseBasicParsing -ErrorAction Stop | Out-Null + Invoke-WebRequest -Uri "$releaseUrl/$archiveName" -OutFile $archivePath -UseBasicParsing -ErrorAction Stop | Out-Null + + $expectedDigests = @() + foreach ($line in [IO.File]::ReadAllLines($manifestPath)) { + if ($line -cmatch '^([0-9A-Fa-f]{64}) ([^\r\n]+)$' -and $Matches[2] -ceq $archiveName) { + $expectedDigests += $Matches[1] + } + } + if ($expectedDigests.Count -ne 1) { + throw "Checksum manifest must contain exactly one SHA-256 entry for $archiveName." + } + $actualDigest = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash + if (-not [string]::Equals($actualDigest, $expectedDigests[0], [StringComparison]::OrdinalIgnoreCase)) { + throw "SHA-256 verification failed for $archiveName." + } + + $members = @(& $tarCommand.Source -tzf $archivePath 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "Could not inspect $archiveName." + } + $members = @($members | ForEach-Object { $_.ToString().Trim() } | Where-Object { $_ -ne '' }) + if ($members.Count -ne 1 -or $members[0] -cne 'turbo.exe') { + throw "Unexpected archive contents in $archiveName; expected only turbo.exe." + } + + $extractDirectory = Join-Path $workDirectory 'extracted' + [IO.Directory]::CreateDirectory($extractDirectory) | Out-Null + & $tarCommand.Source -xzf $archivePath -C $extractDirectory + if ($LASTEXITCODE -ne 0) { + throw "Could not extract $archiveName." + } + $extractedBinary = Join-Path $extractDirectory 'turbo.exe' + if (-not (Test-Path -LiteralPath $extractedBinary -PathType Leaf)) { + throw 'Archive did not contain a regular turbo.exe file.' + } + $attributes = [IO.File]::GetAttributes($extractedBinary) + if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'Archive turbo.exe must not be a symbolic link or reparse point.' + } + + [IO.Directory]::CreateDirectory($InstallDirectory) | Out-Null + if (Test-Path -LiteralPath $destination) { + throw "$destination already exists; it was left untouched. Remove it yourself or choose another install directory." + } + $stagedBinary = Join-Path $InstallDirectory ('.turbo.exe.install.' + [Guid]::NewGuid().ToString('N')) + [IO.File]::Copy($extractedBinary, $stagedBinary, $false) + [IO.File]::Move($stagedBinary, $destination) + $stagedBinary = $null + + if (-not $NoModifyPath) { + try { + Add-TurboToUserPath -Directory $InstallDirectory + if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_PATH)) { + Write-Host "Added $InstallDirectory to the GitHub Actions PATH." + } else { + Write-Host "Added $InstallDirectory to the user PATH. Open a new terminal for it to take effect there." + } + } catch { + Write-Warning "turbo was installed, but PATH could not be updated. Add $InstallDirectory to PATH manually." + } + } else { + Write-Host "PATH was not changed. Add $InstallDirectory to PATH to run turbo by name." + } + Write-Host "Installed turbo $Version at $destination" +} finally { + if ($null -ne $stagedBinary -and (Test-Path -LiteralPath $stagedBinary)) { + Remove-Item -LiteralPath $stagedBinary -Force + } + if (Test-Path -LiteralPath $workDirectory) { + Remove-Item -LiteralPath $workDirectory -Recurse -Force + } +} +} + +Invoke-TurboInstaller @installerArguments diff --git a/scripts/install.test.mjs b/scripts/install.test.mjs new file mode 100644 index 0000000000000..adfe498147b55 --- /dev/null +++ b/scripts/install.test.mjs @@ -0,0 +1,346 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + symlink, + writeFile +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const VERSION = "2.11.5"; +const installerPath = fileURLToPath( + new URL("../apps/docs/public/install", import.meta.url) +); + +function currentTarget() { + const targets = { + darwin: { arm64: "aarch64-apple-darwin", x64: "x86_64-apple-darwin" }, + linux: { + arm64: "aarch64-unknown-linux-musl", + x64: "x86_64-unknown-linux-musl" + } + }; + return targets[process.platform]?.[process.arch] ?? null; +} + +async function createHarness(root, { corruptArchive = false } = {}) { + const target = currentTarget(); + assert.ok( + target, + `unsupported test host: ${process.platform}/${process.arch}` + ); + const fakeBin = join(root, "bin"); + const artifactDir = join(root, "artifact"); + const home = join(root, "home"); + await mkdir(fakeBin); + await mkdir(artifactDir); + await mkdir(home); + + const executable = join(artifactDir, "turbo"); + const binaryContents = "standalone turbo fixture\n"; + await writeFile(executable, binaryContents); + await chmod(executable, 0o755); + + const archiveName = `turbo-${VERSION}-${target}.tar.gz`; + const validArchive = join(root, archiveName); + execFileSync("tar", ["-czf", validArchive, "-C", artifactDir, "turbo"]); + const archiveBytes = await readFile(validArchive); + const digest = createHash("sha256").update(archiveBytes).digest("hex"); + const checksums = join(root, "SHA256SUMS"); + await writeFile(checksums, `${digest} ${archiveName}\n`); + + let servedArchive = validArchive; + if (corruptArchive) { + servedArchive = join(root, "corrupt.tar.gz"); + await writeFile( + servedArchive, + Buffer.concat([archiveBytes, Buffer.from("corrupt")]) + ); + } + + const latest = join(root, "latest.json"); + await writeFile(latest, JSON.stringify({ tag_name: `v${VERSION}` })); + const curlLog = join(root, "curl.log"); + const fakeCurl = join(fakeBin, "curl"); + await writeFile( + fakeCurl, + `#!/bin/sh +set -eu +output= +url= +while [ "$#" -gt 0 ]; do + case "$1" in + --output|-o) output=$2; shift 2 ;; + --header|--proto|--proto-redir) shift 2 ;; + --fail|--show-error|--silent|--location|--tlsv1.2) shift ;; + *) url=$1; shift ;; + esac +done +[ -n "$output" ] && [ -n "$url" ] || { echo "unexpected curl arguments" >&2; exit 2; } +printf '%s\\n' "$url" >> "$TURBO_CURL_LOG" +case "$url" in + */repos/vercel/turborepo/releases/latest) cp "$TURBO_LATEST_FIXTURE" "$output" ;; + */SHA256SUMS) cp "$TURBO_CHECKSUMS_FIXTURE" "$output" ;; + */"$TURBO_ARCHIVE_NAME") cp "$TURBO_ARCHIVE_FIXTURE" "$output" ;; + *) echo "unexpected URL: $url" >&2; exit 22 ;; +esac +` + ); + await chmod(fakeCurl, 0o755); + + return { + target, + archiveName, + binaryContents, + checksums, + curlLog, + env: { + ...process.env, + PATH: `${fakeBin}:/usr/bin:/bin:/usr/sbin:/sbin`, + HOME: home, + SHELL: "/bin/zsh", + TURBO_CURL_LOG: curlLog, + TURBO_VERSION: "", + TURBO_LATEST_FIXTURE: latest, + TURBO_CHECKSUMS_FIXTURE: checksums, + TURBO_ARCHIVE_FIXTURE: servedArchive, + TURBO_ARCHIVE_NAME: archiveName, + TURBO_INSTALL_DIR: join(root, "install's dir", "bin") + } + }; +} + +function runInstaller(env) { + return spawnSync("/bin/sh", [installerPath], { encoding: "utf8", env }); +} + +test("installs the latest stable standalone archive after verifying its manifest digest", async (t) => { + if (process.platform === "win32") { + t.skip("POSIX installer tests run on macOS and Linux"); + return; + } + const root = await mkdtemp(join(tmpdir(), "turbo-install-test-")); + try { + const harness = await createHarness(root); + const result = runInstaller(harness.env); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, new RegExp(`Installed turbo ${VERSION}`)); + assert.equal( + await readFile(join(harness.env.TURBO_INSTALL_DIR, "turbo"), "utf8"), + harness.binaryContents + ); + const profile = await readFile(join(harness.env.HOME, ".zprofile"), "utf8"); + assert.ok( + profile.includes( + `Turborepo standalone installer PATH: ${harness.env.TURBO_INSTALL_DIR}` + ) + ); + const profileCheck = spawnSync( + "/bin/sh", + [ + "-c", + '. "$1"; case ":$PATH:" in *:"$EXPECTED_INSTALL_DIR":*) exit 0 ;; *) exit 1 ;; esac', + "sh", + join(harness.env.HOME, ".zprofile") + ], + { + encoding: "utf8", + env: { + ...harness.env, + PATH: "/usr/bin:/bin", + EXPECTED_INSTALL_DIR: harness.env.TURBO_INSTALL_DIR + } + } + ); + assert.equal(profileCheck.status, 0, profileCheck.stderr); + + const requests = await readFile(harness.curlLog, "utf8"); + assert.match( + requests, + /api\.github\.com\/repos\/vercel\/turborepo\/releases\/latest/ + ); + assert.match( + requests, + new RegExp(`/v${VERSION}/${harness.archiveName.replaceAll(".", "\\.")}`) + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("adds the install directory to GitHub Actions PATH without editing shell profiles", async (t) => { + if (process.platform === "win32") { + t.skip("POSIX installer tests run on macOS and Linux"); + return; + } + const root = await mkdtemp(join(tmpdir(), "turbo-install-test-")); + try { + const harness = await createHarness(root); + const githubPath = join(root, "github-path"); + await writeFile(githubPath, ""); + const result = runInstaller({ ...harness.env, GITHUB_PATH: githubPath }); + assert.equal(result.status, 0, result.stderr); + assert.equal( + (await readFile(githubPath, "utf8")).trim(), + harness.env.TURBO_INSTALL_DIR + ); + assert.deepEqual(await readdir(harness.env.HOME), []); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("rejects a tampered archive without installing it", async (t) => { + if (process.platform === "win32") { + t.skip("POSIX installer tests run on macOS and Linux"); + return; + } + const root = await mkdtemp(join(tmpdir(), "turbo-install-test-")); + try { + const harness = await createHarness(root, { corruptArchive: true }); + const result = runInstaller(harness.env); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /SHA-256 verification failed/); + await assert.rejects( + readFile(join(harness.env.TURBO_INSTALL_DIR, "turbo")) + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("refuses an existing PATH shim before downloading or executing it", async (t) => { + if (process.platform === "win32") { + t.skip("POSIX installer tests run on macOS and Linux"); + return; + } + const root = await mkdtemp(join(tmpdir(), "turbo-install-test-")); + try { + const harness = await createHarness(root); + const targetDirectory = join(root, "existing-target"); + const pathDirectory = join(root, "existing-path"); + const marker = join(root, "existing-turbo-was-run"); + await mkdir(targetDirectory); + await mkdir(pathDirectory); + const target = join(targetDirectory, "turbo"); + const shim = join(pathDirectory, "turbo"); + await writeFile(target, `#!/bin/sh\nprintf 'ran' > '${marker}'\n`); + await chmod(target, 0o755); + await symlink(target, shim); + + const result = runInstaller({ + ...harness.env, + PATH: `${pathDirectory}:${harness.env.PATH}`, + TURBO_VERSION: VERSION + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /An existing "turbo" alias was found on PATH/); + assert.match( + result.stderr, + /Uninstall it and then retry installing the new version again/ + ); + assert.ok(result.stderr.includes(shim)); + await assert.rejects(readFile(harness.curlLog)); + await assert.rejects(readFile(marker)); + await assert.rejects( + readFile(join(harness.env.TURBO_INSTALL_DIR, "turbo")) + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("leaves an existing install untouched and fails before downloading", async (t) => { + if (process.platform === "win32") { + t.skip("POSIX installer tests run on macOS and Linux"); + return; + } + const root = await mkdtemp(join(tmpdir(), "turbo-install-test-")); + try { + const harness = await createHarness(root); + const installDirectory = harness.env.TURBO_INSTALL_DIR; + await mkdir(installDirectory, { recursive: true }); + await writeFile(join(installDirectory, "turbo"), "existing executable"); + const result = runInstaller({ ...harness.env, TURBO_VERSION: VERSION }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /already exists; it was left untouched/); + assert.equal( + await readFile(join(installDirectory, "turbo"), "utf8"), + "existing executable" + ); + await assert.rejects(readFile(harness.curlLog)); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("rejects install directories that cannot be represented safely in PATH", async (t) => { + if (process.platform === "win32") { + t.skip("POSIX installer tests run on macOS and Linux"); + return; + } + const root = await mkdtemp(join(tmpdir(), "turbo-install-test-")); + try { + const harness = await createHarness(root); + const result = runInstaller({ + ...harness.env, + TURBO_INSTALL_DIR: `${root}/unsafe\npath`, + TURBO_VERSION: VERSION + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /must not contain a colon or line break/); + await assert.rejects(readFile(harness.curlLog)); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("installs when SHELL is unset", async (t) => { + if (process.platform === "win32") { + t.skip("POSIX installer tests run on macOS and Linux"); + return; + } + const root = await mkdtemp(join(tmpdir(), "turbo-install-test-")); + try { + const harness = await createHarness(root); + const env = { ...harness.env, TURBO_VERSION: VERSION }; + delete env.SHELL; + const result = runInstaller(env); + assert.equal(result.status, 0, result.stderr); + assert.equal( + await readFile(join(env.TURBO_INSTALL_DIR, "turbo"), "utf8"), + harness.binaryContents + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("rejects unsafe version input before making a request", async (t) => { + if (process.platform === "win32") { + t.skip("POSIX installer tests run on macOS and Linux"); + return; + } + const root = await mkdtemp(join(tmpdir(), "turbo-install-test-")); + try { + const harness = await createHarness(root); + const result = runInstaller({ + ...harness.env, + TURBO_VERSION: "../../latest" + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /invalid release version/); + await assert.rejects(readFile(harness.curlLog)); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/install.windows.test.ps1 b/scripts/install.windows.test.ps1 new file mode 100644 index 0000000000000..ad6b56ce08a26 --- /dev/null +++ b/scripts/install.windows.test.ps1 @@ -0,0 +1,220 @@ +$ErrorActionPreference = 'Stop' +$installerPath = Join-Path $PSScriptRoot '..\apps\docs\public\install.ps1' +$testRoot = Join-Path ([IO.Path]::GetTempPath()) ("turbo-installer-test-" + [Guid]::NewGuid().ToString('N')) +$previousGitHubPath = $env:GITHUB_PATH +$previousTurboVersion = $env:TURBO_VERSION +$previousTurboInstallDirectory = $env:TURBO_INSTALL_DIR +$previousPath = $env:Path +$script:RequestCount = 0 +$script:ChecksumFixture = $null +$script:ArchiveFixture = $null +$script:ArchiveName = $null + +function Assert-True { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { + throw $Message + } +} + +function Invoke-WebRequest { + [CmdletBinding()] + param( + [string]$Uri, + [string]$OutFile, + [switch]$UseBasicParsing, + [hashtable]$Headers + ) + + $script:RequestCount++ + if ($Uri -like '*/repos/vercel/turborepo/releases/latest') { + return [pscustomobject]@{ Content = '{"tag_name":"v2.11.5"}' } + } + if ($Uri -like '*/SHA256SUMS' -and $OutFile) { + Copy-Item -LiteralPath $script:ChecksumFixture -Destination $OutFile + return + } + if ($Uri -like "*/$script:ArchiveName" -and $OutFile) { + Copy-Item -LiteralPath $script:ArchiveFixture -Destination $OutFile + return + } + throw "Unexpected installer request: $Uri" +} + +try { + [IO.Directory]::CreateDirectory($testRoot) | Out-Null + $githubPathFixture = Join-Path $testRoot 'github-path' + [IO.File]::WriteAllText($githubPathFixture, '') + $env:GITHUB_PATH = $githubPathFixture + $expectedInstallDirectory = Join-Path $testRoot 'install dir' + $existingTurboCommand = Get-Command -Name 'turbo' -All -ErrorAction SilentlyContinue | + Where-Object { $_.CommandType -in @('Application', 'ExternalScript', 'Script') } | + Select-Object -First 1 + if ($null -ne $existingTurboCommand) { + $existingTurboPath = $existingTurboCommand.Source + if ([string]::IsNullOrWhiteSpace($existingTurboPath)) { + $existingTurboPath = $existingTurboCommand.Path + } + if ([string]::IsNullOrWhiteSpace($existingTurboPath)) { + $existingTurboPath = $existingTurboCommand.Definition + } + $realCollisionInstallDirectory = Join-Path $testRoot 'real-path-collision-install' + $failed = $false + try { + . $installerPath -Version '2.11.5' -InstallDirectory $realCollisionInstallDirectory -NoModifyPath + } catch { + $failureMessage = $_.Exception.Message + $failed = $failureMessage -match 'An existing "turbo" alias was found on PATH' + Assert-True ($failureMessage.Contains($existingTurboPath)) 'PATH collision error omitted the real executable path.' + Assert-True ($failureMessage -match 'Uninstall it and then retry installing the new version again') 'Installer omitted the retry instruction.' + } + Assert-True $failed 'Installer did not reject the real existing turbo command on PATH.' + Assert-True ($script:RequestCount -eq 0) 'Installer downloaded files before refusing the real PATH collision.' + Assert-True (-not (Test-Path -LiteralPath $realCollisionInstallDirectory)) 'Installer wrote files before refusing the real PATH collision.' + Assert-True ([IO.File]::ReadAllLines($githubPathFixture).Count -eq 0) 'Installer modified GITHUB_PATH before refusing the real PATH collision.' + Write-Output "PASS: refused the existing turbo command at $existingTurboPath without executing it." + } + + $systemPath = @( + (Join-Path $env:SystemRoot 'System32'), + (Join-Path $env:SystemRoot 'System32\Wbem'), + (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0') + ) -join [IO.Path]::PathSeparator + $env:Path = $systemPath + $architecture = $env:PROCESSOR_ARCHITECTURE + if (-not [string]::IsNullOrWhiteSpace($env:PROCESSOR_ARCHITEW6432)) { + $architecture = $env:PROCESSOR_ARCHITEW6432 + } + + if ($architecture -notin @('AMD64', 'x86_64')) { + $failed = $false + try { + . $installerPath -Version '2.11.5' -InstallDirectory $expectedInstallDirectory -NoModifyPath + } catch { + $failed = $_.Exception.Message -match 'Unsupported Windows architecture' + } + Assert-True $failed "Installer did not reject unsupported Windows architecture $architecture." + Assert-True ($script:RequestCount -eq 0) 'Installer made a network request before rejecting the architecture.' + Assert-True (-not (Test-Path -LiteralPath $expectedInstallDirectory)) 'Installer changed files before rejecting the architecture.' + Write-Output "PASS: rejected unsupported Windows architecture $architecture before download or filesystem changes." + $env:PROCESSOR_ARCHITECTURE = 'AMD64' + Remove-Item Env:PROCESSOR_ARCHITEW6432 -ErrorAction SilentlyContinue + Write-Output 'Continuing with simulated AMD64 metadata for fixture-only installer tests; no downloaded executable is run.' + } + + $unsafeInstallDirectory = Join-Path $testRoot 'unsafe;path' + $failed = $false + try { + . $installerPath -Version '2.11.5' -InstallDirectory $unsafeInstallDirectory -NoModifyPath + } catch { + $failed = $_.Exception.Message -match 'must not contain a semicolon or line break' + } + Assert-True $failed 'Installer accepted an install directory that would inject another PATH entry.' + Assert-True (-not (Test-Path -LiteralPath $unsafeInstallDirectory)) 'Installer created an unsafe install directory.' + Assert-True ($script:RequestCount -eq 0) 'Installer made a network request before rejecting an unsafe install directory.' + + $tarCommand = Get-Command tar.exe -ErrorAction Stop + $fixtureDirectory = Join-Path $testRoot 'fixture' + [IO.Directory]::CreateDirectory($fixtureDirectory) | Out-Null + [IO.File]::WriteAllText((Join-Path $fixtureDirectory 'turbo.exe'), 'standalone turbo fixture') + $script:ArchiveName = 'turbo-2.11.5-x86_64-pc-windows-msvc.tar.gz' + $script:ArchiveFixture = Join-Path $testRoot $script:ArchiveName + Push-Location $fixtureDirectory + try { + & $tarCommand.Source -czf $script:ArchiveFixture 'turbo.exe' + Assert-True ($LASTEXITCODE -eq 0) 'Could not create the test archive.' + } finally { + Pop-Location + } + + $digest = (Get-FileHash -LiteralPath $script:ArchiveFixture -Algorithm SHA256).Hash.ToLowerInvariant() + $script:ChecksumFixture = Join-Path $testRoot 'SHA256SUMS' + [IO.File]::WriteAllText($script:ChecksumFixture, "$digest $script:ArchiveName`n") + $script:RequestCount = 0 + + $env:TURBO_VERSION = 'latest' + $env:TURBO_INSTALL_DIR = $expectedInstallDirectory + $Version = '9.9.9' + $InstallDirectory = Join-Path $testRoot 'ambient-install-directory' + $NoModifyPath = $true + Invoke-Expression ([IO.File]::ReadAllText($installerPath)) + $installedBinary = Join-Path $expectedInstallDirectory 'turbo.exe' + Assert-True (Test-Path -LiteralPath $installedBinary -PathType Leaf) 'Installer did not install turbo.exe.' + Assert-True ([IO.File]::ReadAllText($installedBinary) -eq 'standalone turbo fixture') 'Installed binary contents changed.' + Assert-True ($script:RequestCount -eq 3) 'Expected latest-version, checksum, and archive requests.' + Assert-True ([IO.File]::ReadAllLines($githubPathFixture) -contains $expectedInstallDirectory) 'Installer did not add its directory to GITHUB_PATH.' + Assert-True (($env:Path -split ';') -contains $expectedInstallDirectory) 'Installer did not add its directory to the current PATH.' + Assert-True (-not (Test-Path -LiteralPath $InstallDirectory)) 'Piped execution used an ambient install-directory variable.' + $env:Path = $systemPath + + $badInstallDirectory = Join-Path $testRoot 'bad-digest-install' + [IO.File]::WriteAllText($script:ChecksumFixture, (('0' * 64) + " $script:ArchiveName`n")) + $failed = $false + $failureMessage = '' + try { + . $installerPath -Version '2.11.5' -InstallDirectory $badInstallDirectory -NoModifyPath + } catch { + $failureMessage = $_.Exception.Message + $failed = $failureMessage -match 'SHA-256 verification failed' + } + Assert-True $failed "Installer did not reject a mismatched digest. Received: $failureMessage" + Assert-True (-not (Test-Path -LiteralPath $badInstallDirectory)) 'Installer created files after a digest failure.' + + $requestCountBeforeCollision = $script:RequestCount + $existingDirectory = Join-Path $testRoot 'existing-install' + [IO.Directory]::CreateDirectory($existingDirectory) | Out-Null + $existingBinary = Join-Path $existingDirectory 'turbo.exe' + [IO.File]::WriteAllText($existingBinary, 'keep me') + $failed = $false + try { + . $installerPath -Version '2.11.5' -InstallDirectory $existingDirectory -NoModifyPath + } catch { + $failed = $_.Exception.Message -match 'already exists' + } + Assert-True $failed 'Installer did not refuse to replace an existing turbo.exe.' + Assert-True ([IO.File]::ReadAllText($existingBinary) -eq 'keep me') 'Installer changed the pre-existing turbo.exe.' + Assert-True ($script:RequestCount -eq $requestCountBeforeCollision) 'Installer downloaded files before refusing the existing executable.' + + $collisionDirectory = Join-Path $testRoot 'existing-path' + [IO.Directory]::CreateDirectory($collisionDirectory) | Out-Null + $collisionShim = Join-Path $collisionDirectory 'turbo.cmd' + $collisionMarker = Join-Path $testRoot 'existing-turbo-was-run' + [IO.File]::WriteAllText($collisionShim, "@echo off`r`necho ran > `"$collisionMarker`"`r`n") + $env:Path = "$collisionDirectory;$env:Path" + $requestCountBeforePathCollision = $script:RequestCount + $failed = $false + try { + . $installerPath -Version '2.11.5' -InstallDirectory (Join-Path $testRoot 'path-collision-install') -NoModifyPath + } catch { + $failed = $_.Exception.Message -match 'An existing "turbo" alias was found on PATH' + Assert-True ($_.Exception.Message.Contains($collisionShim)) 'PATH collision error omitted the executable path.' + Assert-True ($_.Exception.Message -match 'Uninstall it and then retry installing the new version again') 'Installer omitted the retry instruction.' + } + Assert-True $failed 'Installer did not refuse an existing turbo.cmd on PATH.' + Assert-True ($script:RequestCount -eq $requestCountBeforePathCollision) 'Installer downloaded files before refusing the PATH collision.' + Assert-True (-not (Test-Path -LiteralPath (Join-Path $testRoot 'path-collision-install'))) 'Installer wrote files before refusing the PATH collision.' + Assert-True (-not (Test-Path -LiteralPath $collisionMarker)) 'Installer executed the pre-existing turbo command.' + $env:Path = $previousPath + + Write-Output 'PASS: Windows installer refuses PATH shims and preserves existing files.' +} finally { + if (Test-Path -LiteralPath $testRoot) { + Remove-Item -LiteralPath $testRoot -Recurse -Force + } + if ([string]::IsNullOrWhiteSpace($previousGitHubPath)) { + Remove-Item Env:GITHUB_PATH -ErrorAction SilentlyContinue + } else { + $env:GITHUB_PATH = $previousGitHubPath + } + if ([string]::IsNullOrWhiteSpace($previousTurboVersion)) { + Remove-Item Env:TURBO_VERSION -ErrorAction SilentlyContinue + } else { + $env:TURBO_VERSION = $previousTurboVersion + } + if ([string]::IsNullOrWhiteSpace($previousTurboInstallDirectory)) { + Remove-Item Env:TURBO_INSTALL_DIR -ErrorAction SilentlyContinue + } else { + $env:TURBO_INSTALL_DIR = $previousTurboInstallDirectory + } + $env:Path = $previousPath +} diff --git a/scripts/package-standalone-archives.mjs b/scripts/package-standalone-archives.mjs index df42ff509b545..6217fcc24ee56 100644 --- a/scripts/package-standalone-archives.mjs +++ b/scripts/package-standalone-archives.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node +import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -10,7 +11,7 @@ export const STANDALONE_TARGETS = [ { triple: "aarch64-apple-darwin", executable: "turbo" }, { triple: "x86_64-unknown-linux-musl", executable: "turbo" }, { triple: "aarch64-unknown-linux-musl", executable: "turbo" }, - { triple: "x86_64-pc-windows-msvc", executable: "turbo.exe" }, + { triple: "x86_64-pc-windows-msvc", executable: "turbo.exe" } ]; const VERSION_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.]+)?$/; @@ -24,7 +25,7 @@ export function archiveName(version, triple) { export function archiveMembers(archivePath) { const output = execFileSync("tar", ["-tzf", archivePath], { - encoding: "utf8", + encoding: "utf8" }); return output.trimEnd().split(/\r?\n/).filter(Boolean); } @@ -33,7 +34,7 @@ export function assertArchiveLayout(archivePath, executable) { const members = archiveMembers(archivePath); if (members.length !== 1 || members[0] !== executable) { throw new Error( - `${basename(archivePath)} must contain only ${executable} at its root; found ${members.join(", ") || "no files"}`, + `${basename(archivePath)} must contain only ${executable} at its root; found ${members.join(", ") || "no files"}` ); } } @@ -41,7 +42,7 @@ export function assertArchiveLayout(archivePath, executable) { export function packageStandaloneArchives({ version, artifactsDirectory, - outputDirectory, + outputDirectory }) { if (!VERSION_PATTERN.test(version)) { throw new Error(`Invalid release version: ${version}`); @@ -50,7 +51,7 @@ export function packageStandaloneArchives({ const artifactRoot = resolve(artifactsDirectory); const archiveRoot = resolve(outputDirectory); mkdirSync(archiveRoot, { recursive: true }); - return STANDALONE_TARGETS.map(({ triple, executable }) => { + const archives = STANDALONE_TARGETS.map(({ triple, executable }) => { const binaryPath = join(artifactRoot, `turbo-${triple}`, executable); if (!existsSync(binaryPath)) { throw new Error(`Missing release binary for ${triple}: ${binaryPath}`); @@ -60,20 +61,38 @@ export function packageStandaloneArchives({ execFileSync( "tar", ["-czf", archivePath, "-C", dirname(binaryPath), executable], - { stdio: "inherit" }, + { stdio: "inherit" } ); assertArchiveLayout(archivePath, executable); console.log(`Created ${archivePath}`); return archivePath; }); + + const checksumLines = archives.map((archivePath) => { + const digest = createHash("sha256") + .update(readFileSync(archivePath)) + .digest("hex"); + return `${digest} ${basename(archivePath)}`; + }); + writeFileSync( + join(archiveRoot, "SHA256SUMS"), + `${checksumLines.join("\n")}\n` + ); + return archives; } -if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { - const [version, artifactsDirectory = "rust-artifacts", outputDirectory = "standalone-artifacts"] = - process.argv.slice(2); +if ( + process.argv[1] && + pathToFileURL(resolve(process.argv[1])).href === import.meta.url +) { + const [ + version, + artifactsDirectory = "rust-artifacts", + outputDirectory = "standalone-artifacts" + ] = process.argv.slice(2); if (!version) { console.error( - "Usage: package-standalone-archives.mjs [artifacts-directory] [output-directory]", + "Usage: package-standalone-archives.mjs [artifacts-directory] [output-directory]" ); process.exitCode = 1; } else { @@ -81,7 +100,7 @@ if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.m packageStandaloneArchives({ version, artifactsDirectory, - outputDirectory, + outputDirectory }); } catch (error) { console.error(error.message); diff --git a/scripts/package-standalone-archives.test.mjs b/scripts/package-standalone-archives.test.mjs index 60a735e9b987b..06fdc2b0a2eb7 100644 --- a/scripts/package-standalone-archives.test.mjs +++ b/scripts/package-standalone-archives.test.mjs @@ -1,6 +1,16 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; -import { chmod, mkdtemp, mkdir, readdir, rm, writeFile } from "node:fs/promises"; +import { readFileSync } from "node:fs"; +import { + chmod, + mkdtemp, + mkdir, + readFile, + readdir, + rm, + writeFile +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -10,7 +20,7 @@ import { archiveMembers, archiveName, packageStandaloneArchives, - STANDALONE_TARGETS, + STANDALONE_TARGETS } from "./package-standalone-archives.mjs"; async function createFixtures(root, targets = STANDALONE_TARGETS) { @@ -38,12 +48,12 @@ test("packages versioned archives with one root-level executable per target", as const archives = packageStandaloneArchives({ version, artifactsDirectory, - outputDirectory, + outputDirectory }); assert.deepEqual( archives.map((archive) => basename(archive)), - STANDALONE_TARGETS.map(({ triple }) => archiveName(version, triple)), + STANDALONE_TARGETS.map(({ triple }) => archiveName(version, triple)) ); for (const { triple, executable } of STANDALONE_TARGETS) { const archive = join(outputDirectory, archiveName(version, triple)); @@ -51,10 +61,27 @@ test("packages versioned archives with one root-level executable per target", as const archivedContent = execFileSync( "tar", ["-xOzf", archive, executable], - { encoding: "utf8" }, + { encoding: "utf8" } ); assert.equal(archivedContent, contents.get(triple)); } + + const manifest = ( + await readFile(join(outputDirectory, "SHA256SUMS"), "utf8") + ) + .trimEnd() + .split("\n"); + assert.deepEqual( + manifest, + STANDALONE_TARGETS.map(({ triple }) => { + const filename = archiveName(version, triple); + const archive = join(outputDirectory, filename); + const digest = createHash("sha256") + .update(readFileSync(archive)) + .digest("hex"); + return `${digest} ${filename}`; + }) + ); } finally { await rm(root, { recursive: true, force: true }); } @@ -65,24 +92,27 @@ test("writes workflow artifacts when given relative directories", async () => { try { await createFixtures(root); const scriptPath = fileURLToPath( - new URL("./package-standalone-archives.mjs", import.meta.url), + new URL("./package-standalone-archives.mjs", import.meta.url) ); const version = "2.11.5-canary.4"; execFileSync( process.execPath, [scriptPath, version, "rust-artifacts", "standalone-artifacts"], - { cwd: root, stdio: "ignore" }, + { cwd: root, stdio: "ignore" } ); const outputDirectory = join(root, "standalone-artifacts"); assert.deepEqual( (await readdir(outputDirectory)).sort(), - STANDALONE_TARGETS.map(({ triple }) => archiveName(version, triple)).sort(), + [ + ...STANDALONE_TARGETS.map(({ triple }) => archiveName(version, triple)), + "SHA256SUMS" + ].sort() ); for (const { triple, executable } of STANDALONE_TARGETS) { assert.deepEqual( archiveMembers(join(outputDirectory, archiveName(version, triple))), - [executable], + [executable] ); } } finally { @@ -95,16 +125,16 @@ test("fails if a supported target binary is missing", async () => { try { const { artifactsDirectory } = await createFixtures( root, - STANDALONE_TARGETS.slice(0, -1), + STANDALONE_TARGETS.slice(0, -1) ); assert.throws( () => packageStandaloneArchives({ version: "2.11.5-canary.4", artifactsDirectory, - outputDirectory: join(root, "standalone-artifacts"), + outputDirectory: join(root, "standalone-artifacts") }), - /Missing release binary for x86_64-pc-windows-msvc/, + /Missing release binary for x86_64-pc-windows-msvc/ ); } finally { await rm(root, { recursive: true, force: true }); @@ -114,6 +144,6 @@ test("fails if a supported target binary is missing", async () => { test("rejects versions that cannot be safely used in asset names", () => { assert.throws( () => archiveName("../../latest", "x86_64-apple-darwin"), - /Invalid release version/, + /Invalid release version/ ); });