diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0bb0109..cb9067e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,6 +25,7 @@ jobs: - name: Syntax check run: | bash -n scripts/setup.sh + bash -n scripts/test-setup.sh sh -n installer/install.sh - name: Install ShellCheck @@ -33,6 +34,7 @@ jobs: - name: ShellCheck run: | shellcheck --severity=style scripts/setup.sh + shellcheck --severity=style scripts/test-setup.sh shellcheck --shell=sh --severity=style installer/install.sh lint-powershell: @@ -53,13 +55,14 @@ jobs: shell: pwsh run: | Invoke-ScriptAnalyzer -Path scripts/setup.ps1 -Settings PSGallery -EnableExit + Invoke-ScriptAnalyzer -Path scripts/test-setup.ps1 -Settings PSGallery -EnableExit Invoke-ScriptAnalyzer -Path installer/install.ps1 -Settings PSGallery -EnableExit - name: AST parse check shell: pwsh run: | $ErrorActionPreference = "Stop" - foreach ($file in @("scripts/setup.ps1", "installer/install.ps1")) { + foreach ($file in @("scripts/setup.ps1", "scripts/test-setup.ps1", "installer/install.ps1")) { $tokens = $null $errors = $null [void][System.Management.Automation.Language.Parser]::ParseFile($file, [ref]$tokens, [ref]$errors) @@ -72,6 +75,30 @@ jobs: Write-Output "OK: $file" } + test-setup: + name: Setup regression tests (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Test Bash setup + if: runner.os != 'Windows' + shell: bash + run: bash scripts/test-setup.sh + + - name: Test PowerShell setup + if: runner.os == 'Windows' + shell: pwsh + run: pwsh -NoProfile -File scripts/test-setup.ps1 + installer-drift: name: Vendored installer drift check runs-on: ubuntu-latest @@ -114,7 +141,7 @@ jobs: # values are enough to test the install path without secrets. - name: Install Cloudsmith CLI id: setup - uses: ./ + uses: $/ with: oidc-namespace: placeholder-namespace oidc-service-slug: placeholder-service @@ -159,7 +186,7 @@ jobs: - name: Install Cloudsmith CLI id: setup - uses: ./ + uses: $/ with: # Latest version published to the pre-release test repository. # Bump to the first production release when the vendored installer @@ -198,19 +225,19 @@ jobs: - name: No authentication inputs should fail id: no-auth continue-on-error: true - uses: ./ + uses: $/ - name: Incomplete OIDC pair should fail id: partial-oidc continue-on-error: true - uses: ./ + uses: $/ with: oidc-namespace: placeholder-namespace - name: verify-auth with a bad API key should fail id: bad-key continue-on-error: true - uses: ./ + uses: $/ with: api-key: placeholder-invalid-key verify-auth: "true" @@ -272,7 +299,7 @@ jobs: - name: Install and authenticate id: setup - uses: ./ + uses: $/ with: api-key: ${{ secrets.CLOUDSMITH_API_KEY }} export-auth-token: ${{ matrix.export-auth-token }} @@ -324,7 +351,7 @@ jobs: - name: Install and authenticate id: setup - uses: ./ + uses: $/ with: oidc-namespace: ${{ vars.CLOUDSMITH_NAMESPACE }} oidc-service-slug: ${{ vars.CLOUDSMITH_SERVICE_SLUG }} diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index 6b2005f..2aa13b7 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -21,6 +21,8 @@ Thank you for considering contributing to the Cloudsmith CLI Setup action! bash -n scripts/setup.sh shellcheck --severity=style scripts/setup.sh pwsh -Command "Invoke-ScriptAnalyzer -Path scripts/setup.ps1 -Settings PSGallery -EnableExit" + bash scripts/test-setup.sh + pwsh -NoProfile -File scripts/test-setup.ps1 ``` 4. Commit, push to your fork, and open a pull request. diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 index d255d50..4398981 100644 --- a/scripts/setup.ps1 +++ b/scripts/setup.ps1 @@ -165,8 +165,18 @@ $credentialUsername = '' if ($exportAuthToken -eq 'true') { # Resolve once: the helper performs the OIDC exchange when OIDC is the # effective source and emits a versioned JSON credential document. - $credentialJsonLines = & $executable credential-helper generic - $credentialStatus = $LASTEXITCODE + $previousApiKey = $env:CLOUDSMITH_API_KEY + try { + # Ignore tokens exported by earlier invocations, but keep explicit API keys. + if ($hasOidcNamespace -and -not $hasApiKey) { + $env:CLOUDSMITH_API_KEY = $null + } + $credentialJsonLines = & $executable credential-helper generic + $credentialStatus = $LASTEXITCODE + } + finally { + $env:CLOUDSMITH_API_KEY = $previousApiKey + } if ($credentialStatus -ne 0) { throw "Failed to resolve credentials. 'export-auth-token' requires Cloudsmith CLI 1.21.0 or later and valid credentials." } diff --git a/scripts/setup.sh b/scripts/setup.sh index eaacae4..7c3ad7f 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -156,7 +156,13 @@ if [[ "$export_auth_token" == "true" ]]; then # Resolve once: the helper performs the OIDC exchange when OIDC is the # effective source and emits a versioned JSON credential document. - if ! credential_document="$("$executable" credential-helper generic)"; then + if ! credential_document="$( + # Ignore tokens exported by earlier invocations, but keep explicit API keys. + if [[ "$has_oidc_namespace" == "yes" && -z "$INPUT_API_KEY" ]]; then + unset CLOUDSMITH_API_KEY + fi + "$executable" credential-helper generic + )"; then fail "Failed to resolve credentials. 'export-auth-token' requires Cloudsmith CLI 1.21.0 or later and valid credentials." fi diff --git a/scripts/test-setup.ps1 b/scripts/test-setup.ps1 new file mode 100644 index 0000000..0358df2 --- /dev/null +++ b/scripts/test-setup.ps1 @@ -0,0 +1,118 @@ +$ErrorActionPreference = 'Stop' +$setupScript = Join-Path $PSScriptRoot 'setup.ps1' +$testRoot = Join-Path ([IO.Path]::GetTempPath()) ("cloudsmith-setup-test-" + [Guid]::NewGuid().ToString('N')) + +function Assert-Equal { + param($Actual, $Expected) + if ($Actual -cne $Expected) { + throw "Expected '$Expected', got '$Actual'" + } +} + +function Invoke-SetupTest { + param([string]$Expected) + Set-Content -LiteralPath $env:GITHUB_ENV -Value '' + Set-Content -LiteralPath $env:GITHUB_OUTPUT -Value '' + # Use a fresh process, just like a separate action step. + & (Join-Path $PSHOME 'pwsh') -NoProfile -File $setupScript + Assert-Equal $LASTEXITCODE 0 + foreach ($line in Get-Content -LiteralPath $env:GITHUB_ENV) { + if ($line -match '^([^=]+)=(.*)$') { + Set-Item -Path "Env:$($matches[1])" -Value $matches[2] + } + } + Assert-Equal $env:CLOUDSMITH_API_KEY $Expected + Assert-Equal (Get-Content (Join-Path $testRoot 'verified.log') | Select-Object -Last 1) $Expected + Assert-Equal ((Get-Content $env:GITHUB_ENV) -contains "CLOUDSMITH_API_KEY=$Expected") $true + if ($env:INPUT_EXPORT_AUTH_TOKEN -eq 'true' -or $env:INPUT_OIDC_AUTH_ONLY -eq 'true') { + Assert-Equal $env:CLOUDSMITH_USERNAME 'token' + Assert-Equal ((Get-Content $env:GITHUB_OUTPUT) -contains "oidc-token=$Expected") $true + } +} + +try { + New-Item -ItemType Directory -Path "$testRoot/installer", "$testRoot/bin" | Out-Null + @' +param($Version, $InstallRoot, $OutputFile) +$binDirectory = Join-Path $env:GITHUB_ACTION_PATH 'bin' +$executable = Join-Path $binDirectory 'cloudsmith.ps1' +Set-Content -LiteralPath $OutputFile -Value @( + 'version=1.21.0', 'target=test', "bin_dir=$binDirectory", "executable=$executable" +) +'@ | Set-Content -LiteralPath "$testRoot/installer/install.ps1" + @' +$global:LASTEXITCODE = 0 +switch ($args -join ' ') { + 'credential-helper generic' { + Add-Content "$env:GITHUB_ACTION_PATH/helper.log" 'helper' + # Match CLI precedence: an API key wins over OIDC. + $token = $env:CLOUDSMITH_API_KEY + if ([string]::IsNullOrEmpty($token)) { + Add-Content "$env:GITHUB_ACTION_PATH/exchanges.log" $env:CLOUDSMITH_SERVICE_SLUG + $token = "token-$env:CLOUDSMITH_SERVICE_SLUG" + } + @{ version = 1; username = 'token'; password = $token } | ConvertTo-Json -Compress + } + 'whoami' { Add-Content "$env:GITHUB_ACTION_PATH/verified.log" $env:CLOUDSMITH_API_KEY } + default { throw "Unexpected CLI arguments: $args" } +} +'@ | Set-Content -LiteralPath "$testRoot/bin/cloudsmith.ps1" + + $env:GITHUB_ACTION_PATH = $testRoot + $env:RUNNER_TEMP = $testRoot + $env:GITHUB_ENV = Join-Path $testRoot 'env' + $env:GITHUB_OUTPUT = Join-Path $testRoot 'output' + $env:GITHUB_PATH = Join-Path $testRoot 'path' + $env:GITHUB_REPOSITORY_OWNER = 'test-owner' + $env:ACTIONS_ID_TOKEN_REQUEST_URL = 'https://example.invalid/oidc' + $env:INPUT_CLI_VERSION = 'latest' + $env:INPUT_INSTALL_DIRECTORY = '' + $env:INPUT_API_KEY = '' + $env:INPUT_OIDC_NAMESPACE = 'test-org' + $env:INPUT_OIDC_SERVICE_SLUG = 'pull-only' + $env:INPUT_OIDC_AUDIENCE = '' + $env:INPUT_API_HOST = '' + $env:INPUT_API_PROXY = '' + $env:INPUT_API_SSL_VERIFY = '' + $env:INPUT_API_USER_AGENT = '' + $env:INPUT_VERIFY_AUTH = 'true' + $env:INPUT_EXPORT_AUTH_TOKEN = 'true' + $env:INPUT_OIDC_AUTH_ONLY = 'false' + $env:CLOUDSMITH_API_KEY = $null + + Invoke-SetupTest 'token-pull-only' + $env:INPUT_OIDC_SERVICE_SLUG = 'push-capable' + Invoke-SetupTest 'token-push-capable' + Assert-Equal ((Get-Content "$testRoot/exchanges.log") -join ',') 'pull-only,push-capable' + Assert-Equal @(Get-Content "$testRoot/helper.log").Count 2 + + # Explicit API keys still win, even with OIDC inputs and an inherited token. + $env:INPUT_API_KEY = 'explicit-key' + Invoke-SetupTest 'explicit-key' + $env:INPUT_OIDC_NAMESPACE = '' + $env:INPUT_OIDC_SERVICE_SLUG = '' + $env:INPUT_API_KEY = 'api-only-key' + Invoke-SetupTest 'api-only-key' + Assert-Equal ((Get-Content "$testRoot/exchanges.log") -join ',') 'pull-only,push-capable' + Assert-Equal @(Get-Content "$testRoot/helper.log").Count 4 + + $env:INPUT_EXPORT_AUTH_TOKEN = 'false' + $env:INPUT_API_KEY = 'unexported-key' + Invoke-SetupTest 'unexported-key' + Assert-Equal @(Get-Content "$testRoot/helper.log").Count 4 + Assert-Equal ([bool]((Get-Content $env:GITHUB_OUTPUT) -match '^oidc-token=')) $false + + # The deprecated alias must also ignore the previously exported API key. + $env:INPUT_API_KEY = '' + $env:INPUT_OIDC_NAMESPACE = 'test-org' + $env:INPUT_OIDC_SERVICE_SLUG = 'alias-service' + $env:INPUT_OIDC_AUTH_ONLY = 'true' + Invoke-SetupTest 'token-alias-service' + Assert-Equal ((Get-Content "$testRoot/exchanges.log") -join ',') 'pull-only,push-capable,alias-service' + Write-Output 'PowerShell setup tests passed' +} +finally { + if (Test-Path -LiteralPath $testRoot) { + Remove-Item -LiteralPath $testRoot -Recurse -Force + } +} diff --git a/scripts/test-setup.sh b/scripts/test-setup.sh new file mode 100644 index 0000000..dc034af --- /dev/null +++ b/scripts/test-setup.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +setup_script="$(cd "$(dirname "$0")" && pwd)/setup.sh" +test_root="$(mktemp -d)" +trap 'rm -rf "$test_root"' EXIT +mkdir -p "$test_root/installer" "$test_root/bin" + +cat > "$test_root/installer/install.sh" <<'EOF' +#!/bin/sh +while [ "$1" != "--output-file" ]; do shift; done +printf 'version=1.21.0\ntarget=test\nbin_dir=%s/bin\nexecutable=%s/bin/cloudsmith\n' \ + "$GITHUB_ACTION_PATH" "$GITHUB_ACTION_PATH" > "$2" +EOF + +cat > "$test_root/bin/cloudsmith" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +case "$*" in + "credential-helper generic") + echo helper >> "$GITHUB_ACTION_PATH/helper.log" + # Match CLI precedence: an API key wins over OIDC. + token="${CLOUDSMITH_API_KEY:-}" + if [[ -z "$token" ]]; then + echo "$CLOUDSMITH_SERVICE_SLUG" >> "$GITHUB_ACTION_PATH/exchanges.log" + token="token-$CLOUDSMITH_SERVICE_SLUG" + fi + printf '{"version":1,"username":"token","password":"%s"}\n' "$token" + ;; + whoami) echo "${CLOUDSMITH_API_KEY:-}" >> "$GITHUB_ACTION_PATH/verified.log" ;; + *) exit 1 ;; +esac +EOF +chmod +x "$test_root/bin/cloudsmith" + +export GITHUB_ACTION_PATH="$test_root" RUNNER_TEMP="$test_root" +export GITHUB_ENV="$test_root/env" GITHUB_OUTPUT="$test_root/output" GITHUB_PATH="$test_root/path" +export GITHUB_REPOSITORY_OWNER=test-owner ACTIONS_ID_TOKEN_REQUEST_URL=https://example.invalid/oidc +export INPUT_CLI_VERSION=latest INPUT_INSTALL_DIRECTORY="" INPUT_API_KEY="" +export INPUT_OIDC_NAMESPACE=test-org INPUT_OIDC_SERVICE_SLUG=pull-only INPUT_OIDC_AUDIENCE="" +export INPUT_API_HOST="" INPUT_API_PROXY="" INPUT_API_SSL_VERIFY="" INPUT_API_USER_AGENT="" +export INPUT_VERIFY_AUTH=true INPUT_EXPORT_AUTH_TOKEN=true INPUT_OIDC_AUTH_ONLY=false +unset CLOUDSMITH_API_KEY + +run_setup() { + local expected="$1" line + : > "$GITHUB_ENV" + : > "$GITHUB_OUTPUT" + bash "$setup_script" + # Apply GITHUB_ENV as the runner would before the next action invocation. + while IFS= read -r line; do + export "${line?}" + done < "$GITHUB_ENV" + test "$CLOUDSMITH_API_KEY" = "$expected" + test "$(tail -n 1 "$test_root/verified.log")" = "$expected" + grep -Fx "CLOUDSMITH_API_KEY=$expected" "$GITHUB_ENV" + if [[ "$INPUT_EXPORT_AUTH_TOKEN" == true || "$INPUT_OIDC_AUTH_ONLY" == true ]]; then + test "$CLOUDSMITH_USERNAME" = token + grep -Fx "oidc-token=$expected" "$GITHUB_OUTPUT" + fi +} + +run_setup token-pull-only +export INPUT_OIDC_SERVICE_SLUG=push-capable +run_setup token-push-capable +test "$(cat "$test_root/exchanges.log")" = $'pull-only\npush-capable' +test "$(wc -l < "$test_root/helper.log" | tr -d ' ')" = 2 + +# Explicit API keys still win, even with OIDC inputs and an inherited token. +export INPUT_API_KEY=explicit-key +run_setup explicit-key +export INPUT_OIDC_NAMESPACE="" INPUT_OIDC_SERVICE_SLUG="" INPUT_API_KEY=api-only-key +run_setup api-only-key +test "$(cat "$test_root/exchanges.log")" = $'pull-only\npush-capable' +test "$(wc -l < "$test_root/helper.log" | tr -d ' ')" = 4 + +export INPUT_EXPORT_AUTH_TOKEN=false INPUT_API_KEY=unexported-key +run_setup unexported-key +test "$(wc -l < "$test_root/helper.log" | tr -d ' ')" = 4 +if grep -q '^oidc-token=' "$GITHUB_OUTPUT"; then + echo "Unexpected oidc-token output" >&2 + exit 1 +fi + +# The deprecated alias must also ignore the previously exported API key. +export INPUT_API_KEY="" INPUT_OIDC_NAMESPACE=test-org INPUT_OIDC_SERVICE_SLUG=alias-service +export INPUT_OIDC_AUTH_ONLY=true +run_setup token-alias-service +test "$(cat "$test_root/exchanges.log")" = $'pull-only\npush-capable\nalias-service' +echo "Bash setup tests passed"