diff --git a/.gitattributes b/.gitattributes index 1418fb12d94..5fc58b06b87 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,6 +9,7 @@ pkg/workflow/js/*.cjs linguist-generated=true pkg/workflow/sh/*.sh linguist-generated=true actions/*/index.js linguist-generated=true actions/setup-cli/install.sh linguist-generated=true +actions/setup-cli/install.ps1 linguist-generated=true specs/artifacts.md linguist-generated=true merge=ours docs/adr/*.md merge=theirs # Use bd merge for beads JSONL files diff --git a/.github/workflows/install.yml b/.github/workflows/install.yml index 60315912033..7d0bce74b5b 100644 --- a/.github/workflows/install.yml +++ b/.github/workflows/install.yml @@ -8,6 +8,7 @@ on: pull_request: paths: - 'install-gh-aw.sh' + - 'install-gh-aw.ps1' - 'scripts/test-install-script.sh' - '.github/workflows/install.yml' @@ -185,10 +186,87 @@ jobs: echo "" echo "✅ Full installation test passed!" + test-install-powershell: + name: Test PowerShell installer on Windows + runs-on: windows-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Capture platform-detection output (expects failure for fake version) + # The installer exits 1 when the nonexistent version download fails; that is + # expected. continue-on-error suppresses the step failure so the next step + # can assert the platform-detection messages that ran before the download. + continue-on-error: true + shell: pwsh + run: | + & ./install-gh-aw.ps1 v999.999.999 6>&1 2>&1 | Tee-Object -FilePath install-ps-output.log + + - name: Assert platform-detection messages from dry run + shell: pwsh + run: | + Set-StrictMode -Version Latest + if (-not (Test-Path install-ps-output.log)) { + throw "Installer output file (install-ps-output.log) was not created; Tee-Object may have failed" + } + $outputText = Get-Content install-ps-output.log -Raw + if (-not $outputText) { throw "Installer output file (install-ps-output.log) exists but is empty" } + if ($outputText -notmatch "Detected OS:") { throw "OS detection failed" } + if ($outputText -notmatch "Detected architecture:") { throw "Architecture detection failed" } + if ($outputText -notmatch "Platform:") { throw "Platform string construction failed" } + if ($outputText -notmatch "Failed to download") { throw "Installer did not report expected download failure for fake version" } + + - name: Test setup-cli composite action (Windows) + id: setup-cli-test + uses: ./actions/setup-cli + with: + version: latest + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Verify composite-action binary and installed-version output + shell: pwsh + run: | + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + + $installedVersion = '${{ steps.setup-cli-test.outputs.installed-version }}' + if (-not $installedVersion) { + throw "installed-version output is empty; action output wiring is broken" + } + Write-Host "Composite action installed: $installedVersion" + + # Verify via gh aw commands rather than a hardcoded binary path. + # `gh extension install` registers the extension under gh's own data dir + # (e.g. %APPDATA%\GitHub CLI\extensions on Windows), so using `gh aw` + # is the correct cross-platform verification mechanism. + gh aw version 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { + throw "'gh aw version' failed with exit code $LASTEXITCODE" + } + + gh aw --help 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { + throw "'gh aw --help' failed with exit code $LASTEXITCODE" + } + + - name: Upload PowerShell test results + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: install-test-powershell-windows + path: install-ps-output.log + retention-days: 7 + if-no-files-found: ignore + report-failure: name: Report Installation Failures runs-on: ubuntu-latest - needs: test-install + needs: + - test-install + - test-install-powershell if: failure() permissions: issues: write @@ -211,7 +289,7 @@ jobs: ## Details - The install script testing workflow detected failures when testing the \`install-gh-aw.sh\` script across different platforms. + The install script testing workflow detected failures when testing the \`install-gh-aw.sh\` and \`install-gh-aw.ps1\` scripts across different platforms. Please review the workflow logs to identify which platform(s) failed and investigate the issue. @@ -225,7 +303,7 @@ jobs: ## Next Steps 1. Check the [workflow run logs](${workflowUrl}) for detailed error messages - 2. Review changes to \`install-gh-aw.sh\` that may have caused the failure + 2. Review changes to \`install-gh-aw.sh\` and \`install-gh-aw.ps1\` that may have caused the failure 3. Test the install script locally on the affected platform(s) 4. Fix the issue and verify with \`scripts/test-install-script.sh\` diff --git a/Makefile b/Makefile index 1aceb03316f..255dedd91bb 100644 --- a/Makefile +++ b/Makefile @@ -1032,6 +1032,8 @@ sync-action-scripts: @echo "Syncing install-gh-aw.sh to actions/setup-cli/install.sh..." @cp install-gh-aw.sh actions/setup-cli/install.sh @chmod +x actions/setup-cli/install.sh + @echo "Syncing install-gh-aw.ps1 to actions/setup-cli/install.ps1..." + @cp install-gh-aw.ps1 actions/setup-cli/install.ps1 @echo "✓ Action scripts synced successfully" # Sync install-gh-aw.sh SHA/hash constants in pkg/cli/copilot_setup.go @@ -1219,7 +1221,7 @@ help: @echo " check-stale-lock-files - Fast guard: detect modified .md files without regenerated .lock.yml (no binary needed)" @echo " install - Install binary locally" @echo " sync-action-pins - Sync actions-lock.json from .github/aw to pkg/actionpins/data and pkg/workflow/data (runs automatically during build)" - @echo " sync-action-scripts - Sync install-gh-aw.sh to actions/setup-cli/install.sh (runs automatically during build)" + @echo " sync-action-scripts - Sync install-gh-aw.sh and install-gh-aw.ps1 to actions/setup-cli/ (runs automatically during build)" @echo " sync-install-script-hashes - Update install-gh-aw.sh SHA and SHA256 constants in pkg/cli/copilot_setup.go (runs automatically during update)" @echo " update - Update GitHub Actions and workflows, sync action pins, and rebuild binary" @echo " fix - Apply automatic codemod-style fixes to workflow files (depends on build)" diff --git a/actions/setup-cli/README.md b/actions/setup-cli/README.md index c9106ec7155..fb36d109c94 100644 --- a/actions/setup-cli/README.md +++ b/actions/setup-cli/README.md @@ -169,17 +169,18 @@ The action automatically falls back to direct download when `gh extension instal ## Development -This action is part of the gh-aw repository. The `install.sh` script is generated during the build process by copying from the root `install-gh-aw.sh` file. +This action is part of the gh-aw repository. The `install.sh` and `install.ps1` scripts are generated during the build process by copying from the root `install-gh-aw.sh` and `install-gh-aw.ps1` files. ### Building -The installation script is copied during the build process: +The installation scripts are copied during the build process: ```bash make build # Copies install-gh-aw.sh to actions/setup-cli/install.sh + # and install-gh-aw.ps1 to actions/setup-cli/install.ps1 ``` -The generated `install.sh` file is marked as `linguist-generated=true` in `.gitattributes`. +The generated `install.sh` and `install.ps1` files are marked as `linguist-generated=true` in `.gitattributes`. ## License diff --git a/actions/setup-cli/action.yml b/actions/setup-cli/action.yml index 49e383c4bad..618c5eb4349 100644 --- a/actions/setup-cli/action.yml +++ b/actions/setup-cli/action.yml @@ -14,11 +14,24 @@ inputs: outputs: installed-version: description: 'The version that was installed' + value: ${{ steps.install-windows.outputs.installed_version || steps.install-unix.outputs.installed_version }} runs: using: 'composite' steps: - - name: Install gh-aw CLI + - name: Install gh-aw CLI (PowerShell) + id: install-windows + if: runner.os == 'Windows' + shell: pwsh + env: + INPUT_VERSION: ${{ inputs.version }} + GH_TOKEN: ${{ inputs.github-token }} + ACTION_PATH: ${{ github.action_path }} + run: '& "$env:ACTION_PATH/install.ps1"' + + - name: Install gh-aw CLI (Bash) + id: install-unix + if: runner.os != 'Windows' shell: bash env: INPUT_VERSION: ${{ inputs.version }} diff --git a/actions/setup-cli/install.ps1 b/actions/setup-cli/install.ps1 new file mode 100644 index 00000000000..b4755e8f954 --- /dev/null +++ b/actions/setup-cli/install.ps1 @@ -0,0 +1,437 @@ +#!/usr/bin/env pwsh + +# Script sync note: install-gh-aw.ps1 is canonical. actions/setup-cli/install.ps1 is copied from install-gh-aw.ps1. + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$SkipChecksum = $false +$TryGhInstall = $false +$Version = "" + +function Test-EnvVarPresent { + param([Parameter(Mandatory)][string]$Name) + + return $null -ne (Get-Item "Env:$Name" -ErrorAction SilentlyContinue) +} + +if ($env:INPUT_VERSION) { + $Version = $env:INPUT_VERSION + $TryGhInstall = $true +} + +foreach ($arg in $args) { + switch -Regex ($arg) { + "^(--skip-checksum|-SkipChecksum)$" { + $SkipChecksum = $true + continue + } + "^(--gh-install|-GhInstall)$" { + $TryGhInstall = $true + continue + } + default { + if (-not $Version) { + $Version = $arg + } + } + } +} + +$NoColor = $false +if ($env:CI -or (Test-EnvVarPresent "NO_COLOR") -or (Test-EnvVarPresent "NO_COLORS") -or [Console]::IsOutputRedirected -or $env:TERM -eq "dumb") { + $NoColor = $true +} + +function Write-LogLine { + param( + [Parameter(Mandatory)][string]$Level, + [Parameter(Mandatory)][string]$Message, + [string]$Color = "" + ) + + if ($NoColor -or [string]::IsNullOrEmpty($Color)) { + Write-Host "[$Level] $Message" + return + } + + Write-Host "[$Level]" -ForegroundColor $Color -NoNewline + Write-Host " $Message" +} + +function Write-Info { param([string]$Message) Write-LogLine -Level "INFO" -Message $Message -Color "Blue" } +function Write-Success { param([string]$Message) Write-LogLine -Level "SUCCESS" -Message $Message -Color "Green" } +function Write-WarningMessage { param([string]$Message) Write-LogLine -Level "WARNING" -Message $Message -Color "Yellow" } +function Write-ErrorMessage { param([string]$Message) Write-LogLine -Level "ERROR" -Message $Message -Color "Red" } + +$HomePath = if ($HOME) { $HOME } elseif ($env:USERPROFILE) { $env:USERPROFILE } else { "" } +if (-not $HomePath) { + Write-ErrorMessage "HOME environment variable is not set. Cannot determine installation directory." + exit 1 +} + +$Architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() +switch ($Architecture) { + "X64" { $ArchName = "amd64" } + "Arm64" { $ArchName = "arm64" } + "Arm" { $ArchName = "arm" } + "X86" { $ArchName = "386" } + default { + Write-ErrorMessage "Unsupported architecture: $Architecture" + Write-Info "Supported architectures: X64/amd64, Arm64/arm64, Arm/arm, X86/386" + exit 1 + } +} + +$OSDescription = [System.Runtime.InteropServices.RuntimeInformation]::OSDescription +if ($IsLinux) { + if ($env:ANDROID_ROOT) { + $OSName = "android" + } else { + $OSName = "linux" + } +} elseif ($IsMacOS) { + $OSName = "darwin" +} elseif ($IsWindows) { + $OSName = "windows" +} elseif ($OSDescription -match "FreeBSD") { + $OSName = "freebsd" +} else { + Write-ErrorMessage "Unsupported operating system: $OSDescription" + Write-Info "Supported operating systems: Linux, macOS (Darwin), FreeBSD, Windows, Android (Termux)" + exit 1 +} + +$Platform = "$OSName-$ArchName" +$BinaryName = if ($OSName -eq "windows") { "gh-aw.exe" } else { "gh-aw" } + +Write-Info "Detected OS: $OSDescription -> $OSName" +Write-Info "Detected architecture: $Architecture -> $ArchName" +Write-Info "Platform: $Platform" + +$Repo = "github/gh-aw" +if (-not $Version) { + Write-Info "No version specified, using 'latest'..." + $Version = "latest" +} else { + Write-Info "Using specified version: $Version" +} + +$GitHubHeaders = @{ + Accept = "application/vnd.github+json" + "User-Agent" = "gh-aw-install-gh-aw.ps1" + "X-GitHub-Api-Version" = "2022-11-28" +} +if ($env:GH_TOKEN) { + $GitHubHeaders.Authorization = ("Bearer " + $env:GH_TOKEN) +} + +function Invoke-ProcessWithTimeout { + param( + [Parameter(Mandatory)][string]$FilePath, + [string[]]$ArgumentList = @(), + [int]$TimeoutSeconds = 30 + ) + + $stdoutPath = [System.IO.Path]::GetTempFileName() + $stderrPath = [System.IO.Path]::GetTempFileName() + + try { + $process = Start-Process -FilePath $FilePath -ArgumentList $ArgumentList -PassThru -NoNewWindow -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath + if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { + try { + $process.Kill($true) + $process.WaitForExit(5000) | Out-Null + } catch { + } + $stdout = (Get-Content $stdoutPath -Raw -ErrorAction SilentlyContinue) ?? '' + $stderr = (Get-Content $stderrPath -Raw -ErrorAction SilentlyContinue) ?? '' + return [pscustomobject]@{ + ExitCode = 124 + TimedOut = $true + Stdout = $stdout + Stderr = $stderr + } + } + + $stdout = (Get-Content $stdoutPath -Raw -ErrorAction SilentlyContinue) ?? '' + $stderr = (Get-Content $stderrPath -Raw -ErrorAction SilentlyContinue) ?? '' + return [pscustomobject]@{ + ExitCode = $process.ExitCode + TimedOut = $false + Stdout = $stdout + Stderr = $stderr + } + } finally { + Remove-Item $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue + } +} + +function Invoke-DownloadWithRetry { + param( + [Parameter(Mandatory)][string]$Url, + [Parameter(Mandatory)][string]$DestinationPath, + [int]$MaxRetries = 3, + [int]$InitialDelaySeconds = 2 + ) + + $delay = $InitialDelaySeconds + for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) { + try { + Invoke-WebRequest -Uri $Url -Headers $GitHubHeaders -OutFile $DestinationPath -TimeoutSec 120 | Out-Null + Write-Success "Binary downloaded successfully" + return $true + } catch { + if ($attempt -ge $MaxRetries) { + break + } + + Write-WarningMessage "Download attempt $attempt failed. Retrying in ${delay}s..." + Start-Sleep -Seconds $delay + $delay *= 2 + } + } + + return $false +} + +function Get-RequestedOrInstalledVersion { + param([Parameter(Mandatory)][string]$VersionOutput) + + $match = [regex]::Match($VersionOutput, "v[0-9]+\.[0-9]+\.[0-9]+") + if ($match.Success) { + return $match.Value + } + + return "" +} + +if ($TryGhInstall -and (Get-Command gh -ErrorAction SilentlyContinue)) { + Write-Info "Attempting to install gh-aw using 'gh extension install'..." + + $installArgs = @("extension", "install", $Repo, "--force") + if ($Version -and $Version -ne "latest") { + $installArgs += @("--pin", $Version) + } + + $ghInstallTimeoutSeconds = if ($OSName -eq "windows") { 90 } else { 300 } + if ($OSName -eq "windows") { + Write-Info "Windows detected: wrapping gh extension install with a 90s timeout" + } + + $ghInstallResult = Invoke-ProcessWithTimeout -FilePath "gh" -ArgumentList $installArgs -TimeoutSeconds $ghInstallTimeoutSeconds + $combinedGhInstallOutput = ($ghInstallResult.Stdout + $ghInstallResult.Stderr).Trim() + + if ($ghInstallResult.ExitCode -eq 124) { + Write-WarningMessage "gh extension install timed out (${ghInstallTimeoutSeconds}s) — falling back to manual installation." + Write-WarningMessage "This is a known issue on Windows where Defender scans the new binary." + } elseif ($ghInstallResult.ExitCode -eq 0) { + $verifyGhResult = Invoke-ProcessWithTimeout -FilePath "gh" -ArgumentList @("aw", "version") -TimeoutSeconds 30 + if ($verifyGhResult.ExitCode -eq 0) { + $installedVersion = Get-RequestedOrInstalledVersion -VersionOutput ($verifyGhResult.Stdout + $verifyGhResult.Stderr) + if (-not $installedVersion) { + Write-WarningMessage "gh extension install completed but the installed gh-aw version could not be determined" + Write-Info "Falling back to manual installation..." + } elseif ($Version -ne "latest" -and $installedVersion -ne $Version) { + Write-WarningMessage "Version mismatch: requested $Version but gh extension install installed $installedVersion" + Write-Info "Falling back to manual installation to install the correct version..." + } else { + Write-Success "Successfully installed gh-aw using gh extension install" + Write-Info "Installed version: $installedVersion" + if ($env:GITHUB_OUTPUT) { + "installed_version=$installedVersion" | Add-Content -Path $env:GITHUB_OUTPUT + } + exit 0 + } + } else { + Write-WarningMessage "gh extension install completed but verification failed" + Write-Info "Falling back to manual installation..." + } + } else { + Write-WarningMessage "gh extension install failed, falling back to manual installation..." + if ($combinedGhInstallOutput) { + Write-Host $combinedGhInstallOutput + } + } +} elseif ($TryGhInstall) { + Write-Info "gh CLI not available, proceeding with manual installation..." +} + +if ($Version -eq "latest") { + $DownloadURL = "https://github.com/$Repo/releases/latest/download/$Platform" + $ChecksumsURL = "https://github.com/$Repo/releases/latest/download/checksums.txt" +} else { + $DownloadURL = "https://github.com/$Repo/releases/download/$Version/$Platform" + $ChecksumsURL = "https://github.com/$Repo/releases/download/$Version/checksums.txt" +} +if ($OSName -eq "windows") { + $DownloadURL = "$DownloadURL.exe" +} + +$LatestTag = "" +$FallbackDownloadURL = "" +$FallbackChecksumsURL = "" +if ($Version -eq "latest") { + try { + $latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" -Headers $GitHubHeaders -TimeoutSec 30 + $LatestTag = $latestRelease.tag_name + } catch { + Write-WarningMessage "Failed to resolve latest release tag from GitHub API." + } + + if ($LatestTag) { + $FallbackDownloadURL = "https://github.com/$Repo/releases/download/$LatestTag/$Platform" + $FallbackChecksumsURL = "https://github.com/$Repo/releases/download/$LatestTag/checksums.txt" + if ($OSName -eq "windows") { + $FallbackDownloadURL = "$FallbackDownloadURL.exe" + } + Write-Info "Prepared latest fallback URL for resolved tag: $LatestTag" + } else { + Write-WarningMessage "Could not resolve latest release tag; install will use latest redirect URL only." + } +} + +$InstallDir = [System.IO.Path]::Combine($HomePath, ".local", "share", "gh", "extensions", "gh-aw") +$BinaryPath = Join-Path $InstallDir $BinaryName +$ChecksumsPath = Join-Path $InstallDir "checksums.txt" + +Write-Info "Download URL: $DownloadURL" +Write-Info "Installation directory: $InstallDir" + +if (-not (Test-Path $InstallDir)) { + Write-Info "Creating installation directory..." + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null +} + +if (Test-Path $BinaryPath) { + Write-WarningMessage "Binary '$BinaryPath' already exists. It will be overwritten." +} + +Write-Info "Downloading gh-aw binary..." +if (-not (Invoke-DownloadWithRetry -Url $DownloadURL -DestinationPath $BinaryPath)) { + if ($FallbackDownloadURL) { + Write-WarningMessage "Failed to download from latest redirect URL. Retrying with resolved tag URL ($LatestTag)." + $DownloadURL = $FallbackDownloadURL + $ChecksumsURL = $FallbackChecksumsURL + if (-not (Invoke-DownloadWithRetry -Url $DownloadURL -DestinationPath $BinaryPath)) { + Write-ErrorMessage "Failed to download binary from $DownloadURL after 3 attempts" + Write-Info "Please check if the version and platform combination exists in the releases." + exit 1 + } + } else { + Write-ErrorMessage "Failed to download binary from $DownloadURL after 3 attempts" + Write-Info "Please check if the version and platform combination exists in the releases." + exit 1 + } +} + +if (-not $SkipChecksum) { + Write-Info "Downloading checksums file..." + $checksumsDownloaded = $false + + for ($attempt = 1; $attempt -le 3; $attempt++) { + try { + Invoke-WebRequest -Uri $ChecksumsURL -Headers $GitHubHeaders -OutFile $ChecksumsPath -TimeoutSec 60 | Out-Null + $checksumsDownloaded = $true + Write-Success "Checksums file downloaded successfully" + break + } catch { + if ($attempt -eq 3) { + Write-WarningMessage "Failed to download checksums file after 3 attempts" + Write-WarningMessage "Checksum verification will be skipped for this version." + Write-Info "This may occur for older releases that don't include checksums." + } else { + Write-WarningMessage "Checksum download attempt $attempt failed. Retrying in 2s..." + Start-Sleep -Seconds 2 + } + } + } + + if ($checksumsDownloaded) { + Write-Info "Verifying binary checksum..." + + $expectedFilename = if ($OSName -eq "windows") { "$Platform.exe" } else { $Platform } + $expectedChecksum = "" + foreach ($line in Get-Content $ChecksumsPath) { + $parts = $line -split "\s+", 2 + if ($parts.Count -eq 2 -and $parts[1].Trim() -eq $expectedFilename) { + $expectedChecksum = $parts[0].Trim() + break + } + } + + if (-not $expectedChecksum) { + Write-WarningMessage "Checksum for $expectedFilename not found in checksums file" + Write-WarningMessage "Checksum verification will be skipped." + } else { + $actualChecksum = (Get-FileHash -Path $BinaryPath -Algorithm SHA256).Hash.ToLowerInvariant() + $expectedChecksum = $expectedChecksum.ToLowerInvariant() + + if ($actualChecksum -eq $expectedChecksum) { + Write-Success "Checksum verification passed!" + Write-Info "Expected: $expectedChecksum" + Write-Info "Actual: $actualChecksum" + } else { + Write-ErrorMessage "Checksum verification failed!" + Write-ErrorMessage "Expected: $expectedChecksum" + Write-ErrorMessage "Actual: $actualChecksum" + Write-ErrorMessage "The downloaded binary may be corrupted or tampered with." + Write-Info "To skip checksum verification, use: ./install-gh-aw.ps1 $Version --skip-checksum" + Remove-Item $BinaryPath -Force -ErrorAction SilentlyContinue + exit 1 + } + } + + Remove-Item $ChecksumsPath -Force -ErrorAction SilentlyContinue + } +} else { + Write-WarningMessage "Checksum verification skipped (--skip-checksum flag used)" +} + +if ($OSName -ne "windows") { + Write-Info "Making binary executable..." + & chmod +x -- $BinaryPath +} + +$BinaryExecTimeoutSeconds = 30 +if ($OSName -eq "windows") { + Write-Info "Windows detected: wrapping binary verification with a 30s timeout" +} + +Write-Info "Verifying binary..." +$helpResult = Invoke-ProcessWithTimeout -FilePath $BinaryPath -ArgumentList @("--help") -TimeoutSeconds $BinaryExecTimeoutSeconds +if ($helpResult.ExitCode -eq 0) { + Write-Success "Binary is working correctly!" +} elseif ($OSName -eq "windows" -and $helpResult.ExitCode -eq 124) { + Write-WarningMessage "Binary verification timed out — Windows Defender may still be scanning the binary." + Write-WarningMessage "Installation is complete. Verify manually with: '$BinaryPath' --help" +} else { + Write-ErrorMessage "Binary verification failed. The downloaded file may be corrupted or incompatible." + exit 1 +} + +$fileSize = (Get-Item $BinaryPath).Length +Write-Success "Installation complete!" +Write-Info "Binary location: $BinaryPath" +Write-Info "Binary size: $fileSize bytes" +Write-Info "Version: $Version" + +Write-Info "" +Write-Info "You can now use gh-aw with the gh CLI:" +Write-Info " gh aw --help" +Write-Info " gh aw version" + +Write-Info "" +Write-Info "Running gh-aw version check..." +$versionCheckResult = Invoke-ProcessWithTimeout -FilePath $BinaryPath -ArgumentList @("version") -TimeoutSeconds $BinaryExecTimeoutSeconds +if ($versionCheckResult.ExitCode -eq 124 -and $OSName -eq "windows") { + Write-WarningMessage "Version check timed out (Windows Defender may still be scanning the binary)." +} elseif ($versionCheckResult.Stdout) { + Write-Host $versionCheckResult.Stdout.TrimEnd() +} elseif ($versionCheckResult.Stderr) { + Write-Host $versionCheckResult.Stderr.TrimEnd() +} + +if ($env:GITHUB_OUTPUT) { + "installed_version=$Version" | Add-Content -Path $env:GITHUB_OUTPUT +} diff --git a/docs/src/content/docs/setup/cli.md b/docs/src/content/docs/setup/cli.md index 206ef32cae2..0acd53560cc 100644 --- a/docs/src/content/docs/setup/cli.md +++ b/docs/src/content/docs/setup/cli.md @@ -56,6 +56,11 @@ curl -sL https://raw.githubusercontent.com/github/gh-aw/main/install-gh-aw.sh | curl -sL https://raw.githubusercontent.com/github/gh-aw/main/install-gh-aw.sh | bash -s v0.1.0 # Pinned ``` +```powershell wrap +Invoke-WebRequest https://raw.githubusercontent.com/github/gh-aw/main/install-gh-aw.ps1 -OutFile install-gh-aw.ps1; pwsh -File ./install-gh-aw.ps1 # Latest +Invoke-WebRequest https://raw.githubusercontent.com/github/gh-aw/main/install-gh-aw.ps1 -OutFile install-gh-aw.ps1; pwsh -File ./install-gh-aw.ps1 v0.1.0 # Pinned +``` + This installs to `~/.local/share/gh/extensions/gh-aw/gh-aw` and supports Linux, macOS, FreeBSD, Windows, and Android (Termux), including environments behind corporate firewalls. ### GitHub Actions Setup Action diff --git a/install-gh-aw.ps1 b/install-gh-aw.ps1 new file mode 100644 index 00000000000..b4755e8f954 --- /dev/null +++ b/install-gh-aw.ps1 @@ -0,0 +1,437 @@ +#!/usr/bin/env pwsh + +# Script sync note: install-gh-aw.ps1 is canonical. actions/setup-cli/install.ps1 is copied from install-gh-aw.ps1. + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$SkipChecksum = $false +$TryGhInstall = $false +$Version = "" + +function Test-EnvVarPresent { + param([Parameter(Mandatory)][string]$Name) + + return $null -ne (Get-Item "Env:$Name" -ErrorAction SilentlyContinue) +} + +if ($env:INPUT_VERSION) { + $Version = $env:INPUT_VERSION + $TryGhInstall = $true +} + +foreach ($arg in $args) { + switch -Regex ($arg) { + "^(--skip-checksum|-SkipChecksum)$" { + $SkipChecksum = $true + continue + } + "^(--gh-install|-GhInstall)$" { + $TryGhInstall = $true + continue + } + default { + if (-not $Version) { + $Version = $arg + } + } + } +} + +$NoColor = $false +if ($env:CI -or (Test-EnvVarPresent "NO_COLOR") -or (Test-EnvVarPresent "NO_COLORS") -or [Console]::IsOutputRedirected -or $env:TERM -eq "dumb") { + $NoColor = $true +} + +function Write-LogLine { + param( + [Parameter(Mandatory)][string]$Level, + [Parameter(Mandatory)][string]$Message, + [string]$Color = "" + ) + + if ($NoColor -or [string]::IsNullOrEmpty($Color)) { + Write-Host "[$Level] $Message" + return + } + + Write-Host "[$Level]" -ForegroundColor $Color -NoNewline + Write-Host " $Message" +} + +function Write-Info { param([string]$Message) Write-LogLine -Level "INFO" -Message $Message -Color "Blue" } +function Write-Success { param([string]$Message) Write-LogLine -Level "SUCCESS" -Message $Message -Color "Green" } +function Write-WarningMessage { param([string]$Message) Write-LogLine -Level "WARNING" -Message $Message -Color "Yellow" } +function Write-ErrorMessage { param([string]$Message) Write-LogLine -Level "ERROR" -Message $Message -Color "Red" } + +$HomePath = if ($HOME) { $HOME } elseif ($env:USERPROFILE) { $env:USERPROFILE } else { "" } +if (-not $HomePath) { + Write-ErrorMessage "HOME environment variable is not set. Cannot determine installation directory." + exit 1 +} + +$Architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() +switch ($Architecture) { + "X64" { $ArchName = "amd64" } + "Arm64" { $ArchName = "arm64" } + "Arm" { $ArchName = "arm" } + "X86" { $ArchName = "386" } + default { + Write-ErrorMessage "Unsupported architecture: $Architecture" + Write-Info "Supported architectures: X64/amd64, Arm64/arm64, Arm/arm, X86/386" + exit 1 + } +} + +$OSDescription = [System.Runtime.InteropServices.RuntimeInformation]::OSDescription +if ($IsLinux) { + if ($env:ANDROID_ROOT) { + $OSName = "android" + } else { + $OSName = "linux" + } +} elseif ($IsMacOS) { + $OSName = "darwin" +} elseif ($IsWindows) { + $OSName = "windows" +} elseif ($OSDescription -match "FreeBSD") { + $OSName = "freebsd" +} else { + Write-ErrorMessage "Unsupported operating system: $OSDescription" + Write-Info "Supported operating systems: Linux, macOS (Darwin), FreeBSD, Windows, Android (Termux)" + exit 1 +} + +$Platform = "$OSName-$ArchName" +$BinaryName = if ($OSName -eq "windows") { "gh-aw.exe" } else { "gh-aw" } + +Write-Info "Detected OS: $OSDescription -> $OSName" +Write-Info "Detected architecture: $Architecture -> $ArchName" +Write-Info "Platform: $Platform" + +$Repo = "github/gh-aw" +if (-not $Version) { + Write-Info "No version specified, using 'latest'..." + $Version = "latest" +} else { + Write-Info "Using specified version: $Version" +} + +$GitHubHeaders = @{ + Accept = "application/vnd.github+json" + "User-Agent" = "gh-aw-install-gh-aw.ps1" + "X-GitHub-Api-Version" = "2022-11-28" +} +if ($env:GH_TOKEN) { + $GitHubHeaders.Authorization = ("Bearer " + $env:GH_TOKEN) +} + +function Invoke-ProcessWithTimeout { + param( + [Parameter(Mandatory)][string]$FilePath, + [string[]]$ArgumentList = @(), + [int]$TimeoutSeconds = 30 + ) + + $stdoutPath = [System.IO.Path]::GetTempFileName() + $stderrPath = [System.IO.Path]::GetTempFileName() + + try { + $process = Start-Process -FilePath $FilePath -ArgumentList $ArgumentList -PassThru -NoNewWindow -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath + if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { + try { + $process.Kill($true) + $process.WaitForExit(5000) | Out-Null + } catch { + } + $stdout = (Get-Content $stdoutPath -Raw -ErrorAction SilentlyContinue) ?? '' + $stderr = (Get-Content $stderrPath -Raw -ErrorAction SilentlyContinue) ?? '' + return [pscustomobject]@{ + ExitCode = 124 + TimedOut = $true + Stdout = $stdout + Stderr = $stderr + } + } + + $stdout = (Get-Content $stdoutPath -Raw -ErrorAction SilentlyContinue) ?? '' + $stderr = (Get-Content $stderrPath -Raw -ErrorAction SilentlyContinue) ?? '' + return [pscustomobject]@{ + ExitCode = $process.ExitCode + TimedOut = $false + Stdout = $stdout + Stderr = $stderr + } + } finally { + Remove-Item $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue + } +} + +function Invoke-DownloadWithRetry { + param( + [Parameter(Mandatory)][string]$Url, + [Parameter(Mandatory)][string]$DestinationPath, + [int]$MaxRetries = 3, + [int]$InitialDelaySeconds = 2 + ) + + $delay = $InitialDelaySeconds + for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) { + try { + Invoke-WebRequest -Uri $Url -Headers $GitHubHeaders -OutFile $DestinationPath -TimeoutSec 120 | Out-Null + Write-Success "Binary downloaded successfully" + return $true + } catch { + if ($attempt -ge $MaxRetries) { + break + } + + Write-WarningMessage "Download attempt $attempt failed. Retrying in ${delay}s..." + Start-Sleep -Seconds $delay + $delay *= 2 + } + } + + return $false +} + +function Get-RequestedOrInstalledVersion { + param([Parameter(Mandatory)][string]$VersionOutput) + + $match = [regex]::Match($VersionOutput, "v[0-9]+\.[0-9]+\.[0-9]+") + if ($match.Success) { + return $match.Value + } + + return "" +} + +if ($TryGhInstall -and (Get-Command gh -ErrorAction SilentlyContinue)) { + Write-Info "Attempting to install gh-aw using 'gh extension install'..." + + $installArgs = @("extension", "install", $Repo, "--force") + if ($Version -and $Version -ne "latest") { + $installArgs += @("--pin", $Version) + } + + $ghInstallTimeoutSeconds = if ($OSName -eq "windows") { 90 } else { 300 } + if ($OSName -eq "windows") { + Write-Info "Windows detected: wrapping gh extension install with a 90s timeout" + } + + $ghInstallResult = Invoke-ProcessWithTimeout -FilePath "gh" -ArgumentList $installArgs -TimeoutSeconds $ghInstallTimeoutSeconds + $combinedGhInstallOutput = ($ghInstallResult.Stdout + $ghInstallResult.Stderr).Trim() + + if ($ghInstallResult.ExitCode -eq 124) { + Write-WarningMessage "gh extension install timed out (${ghInstallTimeoutSeconds}s) — falling back to manual installation." + Write-WarningMessage "This is a known issue on Windows where Defender scans the new binary." + } elseif ($ghInstallResult.ExitCode -eq 0) { + $verifyGhResult = Invoke-ProcessWithTimeout -FilePath "gh" -ArgumentList @("aw", "version") -TimeoutSeconds 30 + if ($verifyGhResult.ExitCode -eq 0) { + $installedVersion = Get-RequestedOrInstalledVersion -VersionOutput ($verifyGhResult.Stdout + $verifyGhResult.Stderr) + if (-not $installedVersion) { + Write-WarningMessage "gh extension install completed but the installed gh-aw version could not be determined" + Write-Info "Falling back to manual installation..." + } elseif ($Version -ne "latest" -and $installedVersion -ne $Version) { + Write-WarningMessage "Version mismatch: requested $Version but gh extension install installed $installedVersion" + Write-Info "Falling back to manual installation to install the correct version..." + } else { + Write-Success "Successfully installed gh-aw using gh extension install" + Write-Info "Installed version: $installedVersion" + if ($env:GITHUB_OUTPUT) { + "installed_version=$installedVersion" | Add-Content -Path $env:GITHUB_OUTPUT + } + exit 0 + } + } else { + Write-WarningMessage "gh extension install completed but verification failed" + Write-Info "Falling back to manual installation..." + } + } else { + Write-WarningMessage "gh extension install failed, falling back to manual installation..." + if ($combinedGhInstallOutput) { + Write-Host $combinedGhInstallOutput + } + } +} elseif ($TryGhInstall) { + Write-Info "gh CLI not available, proceeding with manual installation..." +} + +if ($Version -eq "latest") { + $DownloadURL = "https://github.com/$Repo/releases/latest/download/$Platform" + $ChecksumsURL = "https://github.com/$Repo/releases/latest/download/checksums.txt" +} else { + $DownloadURL = "https://github.com/$Repo/releases/download/$Version/$Platform" + $ChecksumsURL = "https://github.com/$Repo/releases/download/$Version/checksums.txt" +} +if ($OSName -eq "windows") { + $DownloadURL = "$DownloadURL.exe" +} + +$LatestTag = "" +$FallbackDownloadURL = "" +$FallbackChecksumsURL = "" +if ($Version -eq "latest") { + try { + $latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" -Headers $GitHubHeaders -TimeoutSec 30 + $LatestTag = $latestRelease.tag_name + } catch { + Write-WarningMessage "Failed to resolve latest release tag from GitHub API." + } + + if ($LatestTag) { + $FallbackDownloadURL = "https://github.com/$Repo/releases/download/$LatestTag/$Platform" + $FallbackChecksumsURL = "https://github.com/$Repo/releases/download/$LatestTag/checksums.txt" + if ($OSName -eq "windows") { + $FallbackDownloadURL = "$FallbackDownloadURL.exe" + } + Write-Info "Prepared latest fallback URL for resolved tag: $LatestTag" + } else { + Write-WarningMessage "Could not resolve latest release tag; install will use latest redirect URL only." + } +} + +$InstallDir = [System.IO.Path]::Combine($HomePath, ".local", "share", "gh", "extensions", "gh-aw") +$BinaryPath = Join-Path $InstallDir $BinaryName +$ChecksumsPath = Join-Path $InstallDir "checksums.txt" + +Write-Info "Download URL: $DownloadURL" +Write-Info "Installation directory: $InstallDir" + +if (-not (Test-Path $InstallDir)) { + Write-Info "Creating installation directory..." + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null +} + +if (Test-Path $BinaryPath) { + Write-WarningMessage "Binary '$BinaryPath' already exists. It will be overwritten." +} + +Write-Info "Downloading gh-aw binary..." +if (-not (Invoke-DownloadWithRetry -Url $DownloadURL -DestinationPath $BinaryPath)) { + if ($FallbackDownloadURL) { + Write-WarningMessage "Failed to download from latest redirect URL. Retrying with resolved tag URL ($LatestTag)." + $DownloadURL = $FallbackDownloadURL + $ChecksumsURL = $FallbackChecksumsURL + if (-not (Invoke-DownloadWithRetry -Url $DownloadURL -DestinationPath $BinaryPath)) { + Write-ErrorMessage "Failed to download binary from $DownloadURL after 3 attempts" + Write-Info "Please check if the version and platform combination exists in the releases." + exit 1 + } + } else { + Write-ErrorMessage "Failed to download binary from $DownloadURL after 3 attempts" + Write-Info "Please check if the version and platform combination exists in the releases." + exit 1 + } +} + +if (-not $SkipChecksum) { + Write-Info "Downloading checksums file..." + $checksumsDownloaded = $false + + for ($attempt = 1; $attempt -le 3; $attempt++) { + try { + Invoke-WebRequest -Uri $ChecksumsURL -Headers $GitHubHeaders -OutFile $ChecksumsPath -TimeoutSec 60 | Out-Null + $checksumsDownloaded = $true + Write-Success "Checksums file downloaded successfully" + break + } catch { + if ($attempt -eq 3) { + Write-WarningMessage "Failed to download checksums file after 3 attempts" + Write-WarningMessage "Checksum verification will be skipped for this version." + Write-Info "This may occur for older releases that don't include checksums." + } else { + Write-WarningMessage "Checksum download attempt $attempt failed. Retrying in 2s..." + Start-Sleep -Seconds 2 + } + } + } + + if ($checksumsDownloaded) { + Write-Info "Verifying binary checksum..." + + $expectedFilename = if ($OSName -eq "windows") { "$Platform.exe" } else { $Platform } + $expectedChecksum = "" + foreach ($line in Get-Content $ChecksumsPath) { + $parts = $line -split "\s+", 2 + if ($parts.Count -eq 2 -and $parts[1].Trim() -eq $expectedFilename) { + $expectedChecksum = $parts[0].Trim() + break + } + } + + if (-not $expectedChecksum) { + Write-WarningMessage "Checksum for $expectedFilename not found in checksums file" + Write-WarningMessage "Checksum verification will be skipped." + } else { + $actualChecksum = (Get-FileHash -Path $BinaryPath -Algorithm SHA256).Hash.ToLowerInvariant() + $expectedChecksum = $expectedChecksum.ToLowerInvariant() + + if ($actualChecksum -eq $expectedChecksum) { + Write-Success "Checksum verification passed!" + Write-Info "Expected: $expectedChecksum" + Write-Info "Actual: $actualChecksum" + } else { + Write-ErrorMessage "Checksum verification failed!" + Write-ErrorMessage "Expected: $expectedChecksum" + Write-ErrorMessage "Actual: $actualChecksum" + Write-ErrorMessage "The downloaded binary may be corrupted or tampered with." + Write-Info "To skip checksum verification, use: ./install-gh-aw.ps1 $Version --skip-checksum" + Remove-Item $BinaryPath -Force -ErrorAction SilentlyContinue + exit 1 + } + } + + Remove-Item $ChecksumsPath -Force -ErrorAction SilentlyContinue + } +} else { + Write-WarningMessage "Checksum verification skipped (--skip-checksum flag used)" +} + +if ($OSName -ne "windows") { + Write-Info "Making binary executable..." + & chmod +x -- $BinaryPath +} + +$BinaryExecTimeoutSeconds = 30 +if ($OSName -eq "windows") { + Write-Info "Windows detected: wrapping binary verification with a 30s timeout" +} + +Write-Info "Verifying binary..." +$helpResult = Invoke-ProcessWithTimeout -FilePath $BinaryPath -ArgumentList @("--help") -TimeoutSeconds $BinaryExecTimeoutSeconds +if ($helpResult.ExitCode -eq 0) { + Write-Success "Binary is working correctly!" +} elseif ($OSName -eq "windows" -and $helpResult.ExitCode -eq 124) { + Write-WarningMessage "Binary verification timed out — Windows Defender may still be scanning the binary." + Write-WarningMessage "Installation is complete. Verify manually with: '$BinaryPath' --help" +} else { + Write-ErrorMessage "Binary verification failed. The downloaded file may be corrupted or incompatible." + exit 1 +} + +$fileSize = (Get-Item $BinaryPath).Length +Write-Success "Installation complete!" +Write-Info "Binary location: $BinaryPath" +Write-Info "Binary size: $fileSize bytes" +Write-Info "Version: $Version" + +Write-Info "" +Write-Info "You can now use gh-aw with the gh CLI:" +Write-Info " gh aw --help" +Write-Info " gh aw version" + +Write-Info "" +Write-Info "Running gh-aw version check..." +$versionCheckResult = Invoke-ProcessWithTimeout -FilePath $BinaryPath -ArgumentList @("version") -TimeoutSeconds $BinaryExecTimeoutSeconds +if ($versionCheckResult.ExitCode -eq 124 -and $OSName -eq "windows") { + Write-WarningMessage "Version check timed out (Windows Defender may still be scanning the binary)." +} elseif ($versionCheckResult.Stdout) { + Write-Host $versionCheckResult.Stdout.TrimEnd() +} elseif ($versionCheckResult.Stderr) { + Write-Host $versionCheckResult.Stderr.TrimEnd() +} + +if ($env:GITHUB_OUTPUT) { + "installed_version=$Version" | Add-Content -Path $env:GITHUB_OUTPUT +} diff --git a/pkg/cli/setup_cli_action_integration_test.go b/pkg/cli/setup_cli_action_integration_test.go index cd3c04d2ff8..d422ec555ac 100644 --- a/pkg/cli/setup_cli_action_integration_test.go +++ b/pkg/cli/setup_cli_action_integration_test.go @@ -10,7 +10,7 @@ import ( "testing" ) -// TestSetupCLIAction tests the setup-cli action's install.sh script +// TestSetupCLIAction tests the setup-cli action's generated install scripts. func TestSetupCLIAction(t *testing.T) { // Get project root wd, err := os.Getwd() @@ -19,11 +19,15 @@ func TestSetupCLIAction(t *testing.T) { } projectRoot := filepath.Join(wd, "..", "..") installScript := filepath.Join(projectRoot, "actions", "setup-cli", "install.sh") + installPowerShellScript := filepath.Join(projectRoot, "actions", "setup-cli", "install.ps1") - // Verify script exists + // Verify scripts exist if _, err := os.Stat(installScript); os.IsNotExist(err) { t.Fatalf("install.sh script not found at: %s", installScript) } + if _, err := os.Stat(installPowerShellScript); os.IsNotExist(err) { + t.Fatalf("install.ps1 script not found at: %s", installPowerShellScript) + } // Verify script is executable info, err := os.Stat(installScript) @@ -34,6 +38,36 @@ func TestSetupCLIAction(t *testing.T) { t.Errorf("install.sh is not executable") } + t.Run("powershell_script_supports_input_version_env", func(t *testing.T) { + content, err := os.ReadFile(installPowerShellScript) + if err != nil { + t.Fatalf("Failed to read install.ps1: %v", err) + } + if !strings.Contains(string(content), "INPUT_VERSION") { + t.Errorf("install.ps1 does not support INPUT_VERSION environment variable") + } + }) + + t.Run("powershell_script_has_gh_extension_install_logic", func(t *testing.T) { + content, err := os.ReadFile(installPowerShellScript) + if err != nil { + t.Fatalf("Failed to read install.ps1: %v", err) + } + if !strings.Contains(string(content), "gh extension install") { + t.Errorf("install.ps1 does not include gh extension install logic") + } + }) + + t.Run("powershell_script_has_checksum_validation", func(t *testing.T) { + content, err := os.ReadFile(installPowerShellScript) + if err != nil { + t.Fatalf("Failed to read install.ps1: %v", err) + } + if !strings.Contains(string(content), "Get-FileHash") { + t.Errorf("install.ps1 does not include checksum validation") + } + }) + // Test script syntax t.Run("script_syntax_valid", func(t *testing.T) { cmd := exec.Command("bash", "-n", installScript) @@ -121,6 +155,24 @@ func TestSetupCLIAction(t *testing.T) { t.Errorf("install.sh is not synced with install-gh-aw.sh. Run 'make sync-action-scripts'") } }) + + t.Run("powershell_script_synced_from_install_gh_aw", func(t *testing.T) { + installGhAwPowerShellScript := filepath.Join(projectRoot, "install-gh-aw.ps1") + + installContent, err := os.ReadFile(installPowerShellScript) + if err != nil { + t.Fatalf("Failed to read install.ps1: %v", err) + } + + installGhAwContent, err := os.ReadFile(installGhAwPowerShellScript) + if err != nil { + t.Fatalf("Failed to read install-gh-aw.ps1: %v", err) + } + + if string(installContent) != string(installGhAwContent) { + t.Errorf("install.ps1 is not synced with install-gh-aw.ps1. Run 'make sync-action-scripts'") + } + }) } // TestSetupCLIActionYAML tests the action.yml file structure @@ -182,6 +234,12 @@ func TestSetupCLIActionYAML(t *testing.T) { if !strings.Contains(contentStr, "GH_TOKEN:") { t.Errorf("action.yml should set GH_TOKEN environment variable") } + if !strings.Contains(contentStr, "runner.os == 'Windows'") { + t.Errorf("action.yml should install with PowerShell on Windows") + } + if !strings.Contains(contentStr, "install.ps1") { + t.Errorf("action.yml should reference install.ps1 for Windows runners") + } // Verify no SHA mention (only release tags) if strings.Contains(strings.ToLower(contentStr), "sha") && !strings.Contains(contentStr, "SHA256") {