diff --git a/.github/workflows/ci_install_script.yml b/.github/workflows/ci_install_script.yml index afe726510..4646d3197 100644 --- a/.github/workflows/ci_install_script.yml +++ b/.github/workflows/ci_install_script.yml @@ -43,6 +43,7 @@ jobs: tombi.toml type-test.toml docs/public/install.sh + docs/public/install.ps1 .github/scripts/install-cross.sh .github/actions/relevant-changes/** .github/actions/set-version/** @@ -387,3 +388,151 @@ jobs: run: | ${{ matrix.exe_name }} --version ${{ matrix.exe_name }} --help + + test-install-powershell: + needs: detect-changes + if: needs.detect-changes.outputs.relevant == 'true' + name: Test install.ps1 (Windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Resolve release artifact metadata + shell: pwsh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + $installer = Get-Content -Raw ./docs/public/install.ps1 + $versionMatch = [regex]::Match( + $installer, + '(?m)^\$LatestStableVersion = "([0-9]+\.[0-9]+\.[0-9]+)"$' + ) + if (-not $versionMatch.Success) { + throw "Failed to resolve LatestStableVersion" + } + + $headers = @{ + Accept = "application/vnd.github+json" + Authorization = "Bearer $env:GITHUB_TOKEN" + "X-GitHub-Api-Version" = "2022-11-28" + } + $requestedVersion = $versionMatch.Groups[1].Value + try { + $release = Invoke-RestMethod ` + -Headers $headers ` + -Uri "https://api.github.com/repos/tombi-toml/tombi/releases/tags/v$requestedVersion" + $version = $requestedVersion + } + catch { + Write-Warning "Release v$requestedVersion is not published yet; falling back to the latest release." + $release = Invoke-RestMethod ` + -Headers $headers ` + -Uri "https://api.github.com/repos/tombi-toml/tombi/releases/latest" + $version = $release.tag_name -replace "^v", "" + } + + $architecture = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { + "aarch64" + } + else { + "x86_64" + } + $assetName = "tombi-cli-$version-$architecture-pc-windows-msvc.zip" + $asset = $release.assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1 + if ($null -eq $asset) { + throw "Failed to resolve release asset $assetName" + } + + $checksum = $asset.digest -replace "^sha256:", "" + if ($checksum -notmatch "^[0-9a-f]{64}$") { + throw "Invalid release asset digest for ${assetName}: $checksum" + } + + "INSTALL_VERSION=$version" >> $env:GITHUB_ENV + "INSTALL_CHECKSUM=$checksum" >> $env:GITHUB_ENV + + - name: Run PowerShell installer without checksum + shell: powershell + run: | + $installDir = Join-Path $env:LOCALAPPDATA "tombi\bin" + $userPathBefore = [Environment]::GetEnvironmentVariable("Path", "User") + $processPathBefore = $env:PATH + $installedPath = & ./docs/public/install.ps1 -Version $env:INSTALL_VERSION + if ($installedPath -ne (Join-Path $installDir "tombi.exe")) { + throw "Unexpected installed path: $installedPath" + } + if ([Environment]::GetEnvironmentVariable("Path", "User") -ne $userPathBefore) { + throw "The installer unexpectedly modified the user PATH." + } + if ($env:PATH -ne $processPathBefore) { + throw "The installer unexpectedly modified the process PATH." + } + & $installedPath --version + & $installedPath --help + + - name: Run PowerShell installer with plain checksum + shell: powershell + run: | + $installDir = Join-Path $env:RUNNER_TEMP "install-powershell-checksum" + & ./docs/public/install.ps1 ` + -Version $env:INSTALL_VERSION ` + -InstallDir $installDir ` + -Checksum $env:INSTALL_CHECKSUM + if (-not (Test-Path -LiteralPath (Join-Path $installDir "tombi.exe") -PathType Leaf)) { + throw "Installed binary was not found." + } + + - name: Run PowerShell installer with sha256 checksum + shell: powershell + run: | + $installDir = Join-Path $env:RUNNER_TEMP "install-powershell-prefixed-checksum" + & ./docs/public/install.ps1 ` + -Version $env:INSTALL_VERSION ` + -InstallDir $installDir ` + -Checksum "sha256:$env:INSTALL_CHECKSUM" + if (-not (Test-Path -LiteralPath (Join-Path $installDir "tombi.exe") -PathType Leaf)) { + throw "Installed binary was not found." + } + + - name: Reject wrong checksum before install + shell: powershell + run: | + $installDir = Join-Path $env:RUNNER_TEMP "install-powershell-bad-checksum" + try { + & ./docs/public/install.ps1 ` + -Version $env:INSTALL_VERSION ` + -InstallDir $installDir ` + -Checksum ("0" * 64) + throw "Installer unexpectedly accepted an incorrect checksum." + } + catch { + if ($_.Exception.Message -notmatch "Checksum verification failed") { + throw + } + } + if (Test-Path -LiteralPath (Join-Path $installDir "tombi.exe")) { + throw "Binary was installed despite the checksum mismatch." + } + + - name: Reject invalid checksum formats + shell: powershell + run: | + $invalidChecksums = @( + "sha1:0123456789abcdef0123456789abcdef01234567", + "1234", + ("z" * 64), + "" + ) + foreach ($checksum in $invalidChecksums) { + try { + & ./docs/public/install.ps1 -Checksum $checksum + throw "Installer unexpectedly accepted checksum '$checksum'." + } + catch { + if ($_.Exception.Message -notmatch "Unsupported checksum format|Invalid checksum") { + throw + } + } + } diff --git a/.github/workflows/release_cli_vscode.yml b/.github/workflows/release_cli_vscode.yml index 0cf74e91a..da5a9e18d 100644 --- a/.github/workflows/release_cli_vscode.yml +++ b/.github/workflows/release_cli_vscode.yml @@ -327,7 +327,7 @@ jobs: if [[ "${REF_NAME}" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then echo "stable_version=${REF_NAME#v}" >> "${GITHUB_OUTPUT}" else - echo "Skipping install.sh update for non-stable tag: ${REF_NAME}" + echo "Skipping installer updates for non-stable tag: ${REF_NAME}" echo "stable_version=" >> "${GITHUB_OUTPUT}" fi @@ -350,7 +350,7 @@ jobs: fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} - - name: Decide whether install.sh should be updated + - name: Decide whether installers should be updated id: should-update if: steps.stable-version.outputs.stable_version != '' shell: bash @@ -359,14 +359,27 @@ jobs: run: | set -euo pipefail semver_re='^[0-9]+\.[0-9]+\.[0-9]+$' - current_version="$( + shell_current_version="$( sed -n 's/^LATEST_STABLE_VERSION="\([0-9]\+\.[0-9]\+\.[0-9]\+\)"$/\1/p' docs/public/install.sh )" + powershell_current_version="$( + # shellcheck disable=SC2016 # Match the literal PowerShell variable name. + sed -n 's/^\$LatestStableVersion = "\([0-9]\+\.[0-9]\+\.[0-9]\+\)"$/\1/p' docs/public/install.ps1 + )" - if [ -z "${current_version}" ]; then + if [ -z "${shell_current_version}" ]; then echo "LATEST_STABLE_VERSION entry not found in docs/public/install.sh" >&2 exit 1 fi + if [ -z "${powershell_current_version}" ]; then + echo "LatestStableVersion entry not found in docs/public/install.ps1" >&2 + exit 1 + fi + if [ "${shell_current_version}" != "${powershell_current_version}" ]; then + echo "Installer stable versions are inconsistent: install.sh=${shell_current_version}, install.ps1=${powershell_current_version}" >&2 + exit 1 + fi + current_version="${shell_current_version}" if ! [[ "${current_version}" =~ ${semver_re} ]]; then echo "LATEST_STABLE_VERSION must be a stable semver, got: ${current_version}" >&2 @@ -400,7 +413,7 @@ jobs: echo "should_update=${should_update}" >> "${GITHUB_OUTPUT}" if [ "${should_update}" != "true" ]; then - echo "Skipping install.sh update because ${STABLE_VERSION} is not newer than ${current_version}." + echo "Skipping installer updates because ${STABLE_VERSION} is not newer than ${current_version}." fi - name: Update embedded stable version @@ -411,13 +424,19 @@ jobs: run: | set -euo pipefail sed -i 's/^LATEST_STABLE_VERSION=.*/LATEST_STABLE_VERSION="'"${STABLE_VERSION}"'"/' docs/public/install.sh + # shellcheck disable=SC2016 # Replace the literal PowerShell variable name. + sed -i 's/^\$LatestStableVersion = .*/$LatestStableVersion = "'"${STABLE_VERSION}"'"/' docs/public/install.ps1 if ! grep -Fxq "LATEST_STABLE_VERSION=\"${STABLE_VERSION}\"" docs/public/install.sh; then echo "Failed to update embedded stable version to ${STABLE_VERSION}" >&2 exit 1 fi + if ! grep -Fxq "\$LatestStableVersion = \"${STABLE_VERSION}\"" docs/public/install.ps1; then + echo "Failed to update PowerShell embedded stable version to ${STABLE_VERSION}" >&2 + exit 1 + fi - - name: Commit updated install script + - name: Commit updated installers id: commit-install-script if: steps.should-update.outputs.should_update == 'true' shell: bash @@ -425,19 +444,19 @@ jobs: STABLE_VERSION: ${{ steps.stable-version.outputs.stable_version }} run: | set -euo pipefail - if git diff --quiet -- docs/public/install.sh; then + if git diff --quiet -- docs/public/install.sh docs/public/install.ps1; then echo "created_commit=false" >> "${GITHUB_OUTPUT}" - echo "install.sh is already up to date." + echo "Installer scripts are already up to date." exit 0 fi git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add docs/public/install.sh - git commit -m "docs: update install.sh stable version to ${STABLE_VERSION}" + git add docs/public/install.sh docs/public/install.ps1 + git commit -m "docs: update installer stable version to ${STABLE_VERSION}" echo "created_commit=true" >> "${GITHUB_OUTPUT}" - - name: Push updated install script to main + - name: Push updated installers to main if: steps.commit-install-script.outputs.created_commit == 'true' shell: bash run: | @@ -451,7 +470,7 @@ jobs: fi if [ "${attempt}" -eq 3 ]; then - echo "Failed to push updated install.sh to main after ${attempt} attempts." >&2 + echo "Failed to push updated installer scripts to main after ${attempt} attempts." >&2 exit 1 fi done diff --git a/docs/public/install.ps1 b/docs/public/install.ps1 new file mode 100644 index 000000000..ff4fd02b5 --- /dev/null +++ b/docs/public/install.ps1 @@ -0,0 +1,228 @@ +<# +.SYNOPSIS +Installs Tombi from GitHub Releases. + +.PARAMETER Version +The Tombi version to install. Defaults to the embedded latest stable version. + +.PARAMETER InstallDir +The directory where tombi.exe will be installed. + +.PARAMETER Checksum +An optional SHA256 checksum, either as hexadecimal or sha256:. +#> +[CmdletBinding()] +param( + [string]$Version, + [string]$InstallDir, + [string]$Checksum +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$ChecksumWasSpecified = $PSBoundParameters.ContainsKey("Checksum") +$InstallDirWasSpecified = $PSBoundParameters.ContainsKey("InstallDir") + +$LatestStableVersion = "1.2.4" +$ReleaseBaseUrl = "https://github.com/tombi-toml/tombi/releases/download" +$ExecutableName = "tombi.exe" + +function Write-Step { + param([string]$Message) + + [Console]::Error.WriteLine("==> {0}", $Message) +} + +function Resolve-Version { + param([string]$RequestedVersion) + + if ([string]::IsNullOrWhiteSpace($RequestedVersion) -or $RequestedVersion -eq "latest") { + return $LatestStableVersion + } + + $ResolvedVersion = $RequestedVersion -replace "^v", "" + if ($ResolvedVersion -notmatch "^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$") { + throw "Invalid version '$RequestedVersion'. Expected a semantic version or 'latest'." + } + + return $ResolvedVersion +} + +function Resolve-Architecture { + $Architecture = $env:PROCESSOR_ARCHITEW6432 + if ([string]::IsNullOrWhiteSpace($Architecture)) { + $Architecture = $env:PROCESSOR_ARCHITECTURE + } + + switch ($Architecture.ToUpperInvariant()) { + "AMD64" { return "x86_64" } + "X86_64" { return "x86_64" } + "ARM64" { return "aarch64" } + default { throw "Unsupported Windows architecture: $Architecture" } + } +} + +function ConvertTo-NormalizedChecksum { + param([string]$ChecksumValue) + + if ([string]::IsNullOrWhiteSpace($ChecksumValue)) { + throw "Invalid checksum: value must not be empty." + } + + $OriginalChecksum = $ChecksumValue + if ($ChecksumValue.StartsWith("sha256:", [StringComparison]::OrdinalIgnoreCase)) { + $ChecksumValue = $ChecksumValue.Substring("sha256:".Length) + } + elseif ($ChecksumValue.Contains(":")) { + throw "Unsupported checksum format '$OriginalChecksum'. Only sha256: is supported." + } + + if ([string]::IsNullOrWhiteSpace($ChecksumValue)) { + throw "Invalid checksum: SHA256 value must not be empty." + } + + if ($ChecksumValue.Length -ne 64) { + throw "Invalid checksum '$OriginalChecksum': expected 64 hex characters for SHA256, got $($ChecksumValue.Length)." + } + + if ($ChecksumValue -notmatch "^[0-9A-Fa-f]{64}$") { + throw "Invalid checksum '$OriginalChecksum': SHA256 must contain only hexadecimal characters." + } + + return $ChecksumValue.ToLowerInvariant() +} + +function Test-PathEntry { + param( + [string]$PathValue, + [string]$Entry + ) + + if ([string]::IsNullOrWhiteSpace($PathValue)) { + return $false + } + + $NormalizedEntry = [IO.Path]::GetFullPath($Entry).TrimEnd("\") + foreach ($Candidate in $PathValue.Split(";")) { + if ([string]::IsNullOrWhiteSpace($Candidate)) { + continue + } + + try { + $ExpandedCandidate = [Environment]::ExpandEnvironmentVariables($Candidate) + $NormalizedCandidate = [IO.Path]::GetFullPath($ExpandedCandidate).TrimEnd("\") + } + catch { + continue + } + + if ($NormalizedCandidate.Equals($NormalizedEntry, [StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + + return $false +} + +function Assert-TombiVersion { + param( + [string]$BinaryPath, + [string]$ExpectedVersion + ) + + $VersionOutputLines = @(& $BinaryPath --version 2>&1) + $VersionExitCode = $LASTEXITCODE + if ($VersionExitCode -ne 0) { + throw "$BinaryPath cannot be executed." + } + $VersionOutput = ($VersionOutputLines | Select-Object -First 1).ToString() + if ($VersionOutput -notmatch "^tombi v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)") { + throw "Unable to determine the installed Tombi version from: $VersionOutput" + } + if ($Matches[1] -ne $ExpectedVersion) { + throw "Installed version mismatch: expected $ExpectedVersion, but got $($Matches[1])." + } +} + +if ($env:OS -ne "Windows_NT") { + throw "install.ps1 only supports Windows." +} + +$ResolvedVersion = Resolve-Version -RequestedVersion $Version +$Architecture = Resolve-Architecture +$Target = "$Architecture-pc-windows-msvc" +$ExpectedChecksum = $null +if ($ChecksumWasSpecified) { + $ExpectedChecksum = ConvertTo-NormalizedChecksum -ChecksumValue $Checksum +} + +if ($InstallDirWasSpecified -and [string]::IsNullOrWhiteSpace($InstallDir)) { + throw "Invalid installation directory: value must not be empty." +} +if (-not $InstallDirWasSpecified) { + if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + throw "LOCALAPPDATA is not available. Specify the installation directory with -InstallDir." + } + $InstallDir = Join-Path $env:LOCALAPPDATA "tombi\bin" +} +$InstallDir = [IO.Path]::GetFullPath($InstallDir) +if (-not (Test-PathEntry -PathValue $env:PATH -Entry $InstallDir)) { + Write-Step "$InstallDir is not in your PATH. Consider adding it to your user PATH." +} + +$AssetName = "tombi-cli-$ResolvedVersion-$Target.zip" +$DownloadUrl = "$ReleaseBaseUrl/v$ResolvedVersion/$AssetName" +$TemporaryDirectory = Join-Path ([IO.Path]::GetTempPath()) ("tombi-install-" + [Guid]::NewGuid()) +$ArchivePath = Join-Path $TemporaryDirectory $AssetName +$ExtractDirectory = Join-Path $TemporaryDirectory "extract" +$InstalledBinary = Join-Path $InstallDir $ExecutableName + +Write-Step "Detected system: $Target" +Write-Step "Installing tombi $ResolvedVersion to $InstallDir" + +try { + New-Item -ItemType Directory -Path $TemporaryDirectory | Out-Null + New-Item -ItemType Directory -Path $ExtractDirectory | Out-Null + New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null + + Write-Step "Downloading $DownloadUrl" + $PreviousSecurityProtocol = [Net.ServicePointManager]::SecurityProtocol + try { + [Net.ServicePointManager]::SecurityProtocol = $PreviousSecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -UseBasicParsing -Uri $DownloadUrl -OutFile $ArchivePath + } + finally { + [Net.ServicePointManager]::SecurityProtocol = $PreviousSecurityProtocol + } + + if ($null -ne $ExpectedChecksum) { + $ActualChecksum = (Get-FileHash -Algorithm SHA256 -LiteralPath $ArchivePath).Hash.ToLowerInvariant() + if ($ActualChecksum -ne $ExpectedChecksum) { + throw "Checksum verification failed. Expected: $ExpectedChecksum Actual: $ActualChecksum" + } + Write-Step "Checksum verification passed." + } + + Expand-Archive -LiteralPath $ArchivePath -DestinationPath $ExtractDirectory -Force + $ExtractedBinary = Get-ChildItem -LiteralPath $ExtractDirectory -Filter $ExecutableName -File -Recurse | + Select-Object -First 1 + if ($null -eq $ExtractedBinary) { + throw "$ExecutableName was not found in the downloaded archive." + } + Assert-TombiVersion -BinaryPath $ExtractedBinary.FullName -ExpectedVersion $ResolvedVersion + + $StagedBinary = Join-Path $InstallDir ("." + $ExecutableName + "." + [Guid]::NewGuid() + ".tmp") + try { + Copy-Item -LiteralPath $ExtractedBinary.FullName -Destination $StagedBinary + Move-Item -LiteralPath $StagedBinary -Destination $InstalledBinary -Force + } + finally { + Remove-Item -LiteralPath $StagedBinary -Force -ErrorAction SilentlyContinue + } + + Write-Step "Tombi $ResolvedVersion was installed successfully." + Write-Output $InstalledBinary +} +finally { + Remove-Item -LiteralPath $TemporaryDirectory -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/docs/src/components/CodeTabs.tsx b/docs/src/components/CodeTabs.tsx index f819d5997..a0128be74 100644 --- a/docs/src/components/CodeTabs.tsx +++ b/docs/src/components/CodeTabs.tsx @@ -5,6 +5,7 @@ export type Tab = { key: string; label: string; command: string; + language?: string; }; type CodeTabsProps = { @@ -44,7 +45,10 @@ export default function CodeTabs(props: CodeTabsProps) { )} - + ); } diff --git a/docs/src/routes/docs/installation.mdx b/docs/src/routes/docs/installation.mdx index d93a3753e..5cb7086c1 100644 --- a/docs/src/routes/docs/installation.mdx +++ b/docs/src/routes/docs/installation.mdx @@ -24,28 +24,99 @@ Choose your preferred installation method: ## CLI -```sh -curl -fsSL https://tombi-toml.github.io/tombi/install.sh | sh -``` + + +On Windows, the default installation directory is `%LOCALAPPDATA%\tombi\bin`, and +the PowerShell installer does not modify your `PATH`; add the directory to your +user `PATH`, or use the returned executable path directly. Install a specific version: -```sh -curl -fsSL https://tombi-toml.github.io/tombi/install.sh | sh -s -- --version 0.7.5 -``` + Install to a specific directory: -(Please specify a directory that is already included in your PATH.) +(For the Shell Script installer, specify a directory that is already included in your `PATH`.) -```sh -curl -fsSL https://tombi-toml.github.io/tombi/install.sh | sh -s -- --install-dir /custom/path -``` + Verify the downloaded archive checksum before extraction: -```sh -curl -fsSL https://tombi-toml.github.io/tombi/install.sh | sh -s -- --checksum -``` +", + language: "sh", + }, + { + key: "powershell", + label: "PowerShell", + command: + "& ([scriptblock]::Create((irm https://tombi-toml.github.io/tombi/install.ps1))) -Checksum ", + language: "powershell", + }, + ]} + defaultKey="sh" + language="sh" +/> ## Windows Package Manager