diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml new file mode 100644 index 0000000..a865829 --- /dev/null +++ b/.github/workflows/parity.yml @@ -0,0 +1,96 @@ +name: Test Bash implementation + +# Tests install.sh on its own and against the Node implementation +# it replaced. End-to-end tests of the action are in test.yml. + +permissions: read-all + +on: + workflow_dispatch: + pull_request: + +jobs: + lint: + runs-on: ubuntu-24.04 + steps: + - name: Harden Runner + uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + + - name: Shellcheck + run: shellcheck install.sh test/*.sh + + unit: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + bash: bash + # macOS /bin/bash is 3.2, which self-hosted macOS runners may still use. + - os: macos-15 + bash: /bin/bash + - os: macos-15 + bash: bash + runs-on: ${{ matrix.os }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + + - name: Unit tests + run: ${{ matrix.bash }} test/unit.sh + + - name: Real download timeout tests + if: runner.os == 'Linux' + run: python3 test/download-timeout.py + + windows-zip: + runs-on: windows-2022 + steps: + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + + - name: ZIP extraction without unzip + shell: bash + run: bash test/windows-zip.sh + + parity: + strategy: + fail-fast: false + matrix: + include: + # Explicit platform/arch cases don't depend on the host, so they run once. + - os: ubuntu-24.04 + mode: full + - os: ubuntu-24.04-arm + mode: host + - os: macos-15 + mode: host + - os: macos-15-intel + mode: host + - os: windows-2022 + mode: host + - os: windows-11-arm + mode: host + runs-on: ${{ matrix.os }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + + - name: Old vs new + shell: bash + run: bash test/parity.sh ${{ matrix.mode }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f3dc07f..9ad7e6e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,14 +13,37 @@ on: description: 'Branch to checkout' required: true default: 'main' + spike_runs: + description: 'Bash start-up attempts for the windows-11-arm spike' + required: false + default: '50' pull_request: jobs: - latest: + interface: + # Only runs: may differ from the last Node release of action.yml. + runs-on: ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + repository: ${{ inputs.repo || github.repository }} + ref: ${{ inputs.branch || github.ref }} + fetch-depth: 0 + + - name: Compare action.yml with the Node baseline + run: | + git show 11452bc:action.yml | yq -o json 'del(.runs)' > old.json + yq -o json 'del(.runs)' action.yml > new.json + diff -u old.json new.json + echo "name, description, author, inputs and outputs are unchanged" + + e2e: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest, macos-13] + os: [ubuntu-22.04, ubuntu-24.04, ubuntu-24.04-arm, windows-2022, windows-2025, windows-11-arm, macos-15, macos-latest, macos-15-intel] + version: [latest, 1.23.0] runs-on: ${{ matrix.os }} steps: - name: Harden Runner @@ -36,54 +59,339 @@ jobs: - name: Install ISPC uses: ./ + with: + version: ${{ matrix.version }} - - name: ISPC version + - name: Check ISPC version + if: matrix.version != 'latest' + shell: pwsh run: | - ispc --version + $output = & ispc --version | Out-String + if ($output -match "${{ matrix.version }}") { + Write-Output "Expected version ${{ matrix.version }} found" + } else { + Write-Output "Expected version ${{ matrix.version }}, got $output" + exit 1 + } + + - name: ISPC version + run: ispc --version - name: ISPC support matrix + run: ispc --support-matrix + + # Shells add their own directories in front of PATH (pwsh, Git Bash's + # /mingw64/bin), so check which ispc each shell resolves, not PATH[0]. + - name: ispc resolves to the installed copy, bash + shell: bash + run: | + found=$(command -v ispc) + echo "ispc resolves to $found" + expected="$GITHUB_WORKSPACE/ispc-releases/" + [[ $RUNNER_OS == Windows ]] && expected="$(cygpath -u "$GITHUB_WORKSPACE")/ispc-releases/" + [[ $found == "$expected"ispc-v*/bin/ispc* ]] + + - name: ispc resolves to the installed copy, pwsh + shell: pwsh + run: | + $found = (Get-Command ispc -CommandType Application | Select-Object -First 1).Source + Write-Output "ispc resolves to $found" + $expected = Join-Path $env:GITHUB_WORKSPACE 'ispc-releases' + if (-not $found.StartsWith($expected + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { exit 1 } + + - name: ispc resolves to the installed copy, cmd + if: runner.os == 'Windows' + shell: cmd run: | - ispc --support-matrix + setlocal EnableDelayedExpansion + set "expected=%GITHUB_WORKSPACE%\ispc-releases\" + for /f "delims=" %%i in ('where ispc') do ( + set "found=%%i" + goto :check + ) + echo ispc not found + exit /b 1 + :check + echo ispc resolves to !found! + if /i "!found:%expected%=!"=="!found!" exit /b 1 + ispc --version - specific: + readme-quickstart: + # README examples as written, with only the default read-all permissions. + runs-on: ubuntu-latest + name: build + steps: + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + repository: ${{ inputs.repo || github.repository }} + ref: ${{ inputs.branch || github.ref }} + - name: install ISPC + uses: ./ + - run: ispc --version + + readme-version: + runs-on: ubuntu-latest + name: build + steps: + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + repository: ${{ inputs.repo || github.repository }} + ref: ${{ inputs.branch || github.ref }} + - name: install ISPC + uses: ./ + with: + version: 1.22.0 + - run: ispc --version | grep -F 1.22.0 + + readme-matrix: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest, macos-13] + include: + - os: ubuntu-latest + platform: linux + + - os: windows-latest + platform: windows + + - os: macos-latest + platform: macOS + runs-on: ${{ matrix.os }} + name: build steps: - - name: Harden Runner - uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + repository: ${{ inputs.repo || github.repository }} + ref: ${{ inputs.branch || github.ref }} + - name: install ISPC + uses: ./ + with: + platform: ${{ matrix.platform }} + - run: ispc --version + + twice: + # Installing twice in one job overwrites like the Node action did. + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-2022, macos-15] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: - egress-policy: audit + repository: ${{ inputs.repo || github.repository }} + ref: ${{ inputs.branch || github.ref }} + + - uses: ./ + with: + version: 1.22.0 + - uses: ./ + with: + version: 1.22.0 + + - uses: ./ + with: + version: 1.23.0 + + - name: The last install wins + shell: bash + run: ispc --version | grep -F 1.23.0 + + caller-defaults: + # The caller's defaults.run don't leak into the composite steps. + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-2022, macos-15] + runs-on: ${{ matrix.os }} + defaults: + run: + shell: pwsh + working-directory: subdir + steps: - name: Checkout repository uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: repository: ${{ inputs.repo || github.repository }} ref: ${{ inputs.branch || github.ref }} - - name: Install ISPC + - name: Create the default working directory + working-directory: . + run: New-Item -ItemType Directory subdir + + - uses: ./ + with: + version: 1.23.0 + + - name: Extracted into the workspace root + run: | + if (-not (Test-Path (Join-Path $env:GITHUB_WORKSPACE 'ispc-releases'))) { exit 1 } + if (Test-Path 'ispc-releases') { Write-Output "Extracted into subdir"; exit 1 } + ispc --version + + negative: + # Invalid input fails the step with an error annotation. + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-2022, macos-15] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + repository: ${{ inputs.repo || github.repository }} + ref: ${{ inputs.branch || github.ref }} + + - id: bad-version + continue-on-error: true + uses: ./ + with: + version: v1.23.0 + + - id: bad-platform + continue-on-error: true uses: ./ with: version: 1.23.0 + platform: macos - - name: Check ISPC version - shell: pwsh + - id: missing-release + continue-on-error: true + uses: ./ + with: + version: 9.9.9 + + - id: injection + continue-on-error: true + uses: ./ + with: + version: $(touch pwned) + + - name: All failed, nothing executed + shell: bash run: | - $output = & ispc --version | Out-String - if ($output -match "1.23.0") { - Write-Output "Expected version 1.23.0 found" - exit 0 - } else { - Write-Output "Expected version 1.23.0, got $output" - exit 1 - } + [[ '${{ steps.bad-version.outcome }}' == failure ]] + [[ '${{ steps.bad-platform.outcome }}' == failure ]] + [[ '${{ steps.missing-release.outcome }}' == failure ]] + [[ '${{ steps.injection.outcome }}' == failure ]] + [[ ! -e pwned ]] + + hostile-env: + # The caller's environment doesn't change how install.sh runs. + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-2022, macos-15] + runs-on: ${{ matrix.os }} + env: + BASH_ENV: ${{ github.workspace }}/test/fixtures/hostile-env.sh + ENV: ${{ github.workspace }}/test/fixtures/hostile-env.sh + SHELLOPTS: xtrace + CDPATH: /tmp + steps: + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + repository: ${{ inputs.repo || github.repository }} + ref: ${{ inputs.branch || github.ref }} + + - uses: ./ + with: + version: 1.23.0 - name: ISPC version - run: | - ispc --version + shell: pwsh + run: ispc --version - - name: ISPC support matrix + container: + # Container jobs with and without the required tools. + runs-on: ubuntu-24.04 + container: ubuntu:24.04 + steps: + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + repository: ${{ inputs.repo || github.repository }} + ref: ${{ inputs.branch || github.ref }} + + - id: bare + continue-on-error: true + uses: ./ + with: + version: 1.23.0 + + - name: Bare image fails with a clear error + run: '[ "${{ steps.bare.outcome }}" = failure ]' + + - name: Install curl only + run: apt-get update -qq && apt-get install -y -qq --no-install-recommends curl ca-certificates + + - id: no-jq + continue-on-error: true + uses: ./ + with: + version: latest + + - name: Latest without jq fails with a clear error + run: '[ "${{ steps.no-jq.outcome }}" = failure ]' + + - name: Pinned version needs neither jq nor git + uses: ./ + with: + version: 1.23.0 + + - run: ispc --version + + - name: Install jq and git + run: apt-get install -y -qq --no-install-recommends jq git + + - name: Latest with all tools + uses: ./ + + - run: ispc --version + + windows-arm-bash: + # Bash sometimes failed to start on windows-11-arm (actions/partner-runner-images#169). + # Spike: run with workflow_dispatch to try many start-ups; pull requests try a few. + runs-on: windows-11-arm + steps: + - name: Start Git Bash repeatedly from Windows PowerShell + shell: powershell + env: + RUNS: ${{ inputs.spike_runs || '5' }} run: | - ispc --support-matrix + $bash = Join-Path $env:ProgramFiles 'Git\bin\bash.exe' + $failures = 0 + for ($i = 1; $i -le [int]$env:RUNS; $i++) { + & $bash --noprofile --norc -c 'exit 0' + if ($LASTEXITCODE -ne 0) { $failures++; Write-Output "Attempt ${i}: exit code $LASTEXITCODE" } + } + Write-Output "$failures of $env:RUNS bash start-ups failed" + if ($failures -gt 0) { exit 1 } + + windows-arm-repeat: + # The composite launcher itself, repeated on windows-11-arm. + strategy: + fail-fast: false + matrix: + attempt: [1, 2, 3, 4, 5] + runs-on: windows-11-arm + steps: + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + repository: ${{ inputs.repo || github.repository }} + ref: ${{ inputs.branch || github.ref }} + + - uses: ./ + with: + version: 1.23.0 + + - run: ispc --version diff --git a/README.md b/README.md index 2d2a06a..2399c1e 100644 --- a/README.md +++ b/README.md @@ -71,3 +71,29 @@ jobs: with: platform: ${{ matrix.platform }} ``` + +## Self-hosted Runners and Containers + +The action is a composite action that runs a Bash script, so it needs these +tools on `PATH`. All of them are preinstalled on GitHub-hosted runners. + +- Always: `bash`, `curl` and `tar`. +- When `version` is empty or `latest`: `jq`, and `git` as a fallback if the + GitHub API is unavailable or rate limited. +- When installing a `windows` release on Linux or macOS: `unzip`. +- On Windows: Git for Windows, which provides `bash`. + +A missing tool fails the step with `install-ispc-action requires '' on PATH`. +For example, a job running in a slim `ubuntu:24.04` container needs these first: + +```yaml + - run: apt-get update && apt-get install -y curl ca-certificates jq git +``` + +If you can't install these tools, pin the previous Node.js-based version of +the action: + +```yaml + - name: install ISPC + uses: ispc/install-ispc-action@v1-node +``` diff --git a/action.yml b/action.yml index e27f380..c067772 100644 --- a/action.yml +++ b/action.yml @@ -12,5 +12,42 @@ inputs: description: "Architecture to download release of ispc for, on MacOS and Linux platforms." required: false runs: - using: 'node24' - main: 'build/main.cjs' + using: composite + steps: + - name: Install ispc + if: runner.os != 'Windows' + # Clean the env before bash starts: the caller's BASH_ENV/ENV/SHELLOPTS must not alter the script. + shell: /usr/bin/env -u ENV -u BASH_ENV -u CDPATH -u SHELLOPTS -u BASHOPTS /bin/sh -eu {0} + working-directory: ${{ github.workspace }} + env: + INPUT_VERSION: ${{ inputs.version }} + INPUT_PLATFORM: ${{ inputs.platform }} + INPUT_ARCHITECTURE: ${{ inputs.architecture }} + ISPC_RUNNER_OS: ${{ runner.os }} + ISPC_RUNNER_ARCH: ${{ runner.arch }} + run: | + command -v bash >/dev/null 2>&1 || { echo "::error::install-ispc-action requires 'bash' on PATH"; exit 1; } + exec bash --noprofile --norc "${GITHUB_ACTION_PATH:?}/install.sh" + + - name: Install ispc + if: runner.os == 'Windows' + # Windows PowerShell 5.1 is always present on Windows (pwsh is not), so it's used as the launcher. + shell: powershell + working-directory: ${{ github.workspace }} + env: + INPUT_VERSION: ${{ inputs.version }} + INPUT_PLATFORM: ${{ inputs.platform }} + INPUT_ARCHITECTURE: ${{ inputs.architecture }} + ISPC_RUNNER_OS: ${{ runner.os }} + ISPC_RUNNER_ARCH: ${{ runner.arch }} + run: | + foreach ($n in 'ENV','BASH_ENV','CDPATH','SHELLOPTS','BASHOPTS') { Remove-Item "Env:$n" -ErrorAction Ignore } + # Prefer Git Bash over other bash.exe on PATH, such as the WSL launcher in System32. + $bash = Join-Path $env:ProgramFiles 'Git\bin\bash.exe' + if (-not (Test-Path -LiteralPath $bash)) { + $cmd = Get-Command bash -CommandType Application -ErrorAction Ignore | Select-Object -First 1 + if (-not $cmd) { Write-Output "::error::install-ispc-action requires 'bash' on PATH"; exit 1 } + $bash = $cmd.Source + } + & $bash --noprofile --norc "$env:GITHUB_ACTION_PATH\install.sh" + exit $LASTEXITCODE diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..81f5798 --- /dev/null +++ b/install.sh @@ -0,0 +1,377 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# +# Installs an ISPC release and adds its bin directory to $GITHUB_PATH. +# +# Inputs (all optional, whitespace-trimmed, empty means unset): +# INPUT_VERSION, INPUT_PLATFORM, INPUT_ARCHITECTURE +# Host detection (set by action.yml from runner.os/runner.arch, falls back to uname): +# ISPC_RUNNER_OS (Linux|macOS|Windows), ISPC_RUNNER_ARCH (X86|X64|ARM|ARM64) +# +# Keep this compatible with bash 3.2 (macOS /bin/bash on self-hosted runners). +# +# Local run: +# RUNNER_TEMP=/tmp/rt GITHUB_PATH=/tmp/p INPUT_VERSION=1.23.0 bash install.sh + +set -euo pipefail + +ISPC_API_URL=${ISPC_API_URL:-https://api.github.com/repos/ispc/ispc/releases/latest} +ISPC_GIT_URL=${ISPC_GIT_URL:-https://github.com/ispc/ispc.git} +ISPC_DOWNLOAD_URL=${ISPC_DOWNLOAD_URL:-https://github.com/ispc/ispc/releases/download} + +# Prints a GitHub error annotation and exits. Escaping matches @actions/core. +fail() { + local msg=$1 + msg=${msg//'%'/'%25'} + msg=${msg//$'\r'/'%0D'} + msg=${msg//$'\n'/'%0A'} + printf '::error::%s\n' "$msg" + exit 1 +} + +info() { + printf '%s\n' "$1" +} + +require() { + command -v "$1" >/dev/null 2>&1 || fail "install-ispc-action requires '$1' on PATH" +} + +# Sets REPLY to the input value with JavaScript String.trim() whitespace removed. +get_input() { + local v=$1 before space + # UTF-8 byte escapes also work in bash 3.2 and with LC_ALL=C. A character + # class would match individual bytes in that locale, rather than whole + # Unicode characters. Do not include NEL, U+180E or zero-width space. + local whitespace=( + $'\t' $'\n' $'\v' $'\f' $'\r' ' ' $'\xc2\xa0' $'\xe1\x9a\x80' + $'\xe2\x80\x80' $'\xe2\x80\x81' $'\xe2\x80\x82' $'\xe2\x80\x83' + $'\xe2\x80\x84' $'\xe2\x80\x85' $'\xe2\x80\x86' $'\xe2\x80\x87' + $'\xe2\x80\x88' $'\xe2\x80\x89' $'\xe2\x80\x8a' $'\xe2\x80\xa8' + $'\xe2\x80\xa9' $'\xe2\x80\xaf' $'\xe2\x81\x9f' $'\xe3\x80\x80' + $'\xef\xbb\xbf' + ) + while :; do + before=$v + for space in "${whitespace[@]}"; do + v=${v#"$space"} + v=${v%"$space"} + done + [[ $v != "$before" ]] || break + done + REPLY=$v +} + +# Sets REPLY to the host OS using Node's os.platform() names. +host_platform() { + case "${ISPC_RUNNER_OS:-}" in + Linux) REPLY=linux ;; + macOS) REPLY=darwin ;; + Windows) REPLY=win32 ;; + '') + local s + s=$(uname -s) || fail "Unable to run uname" + case "$s" in + Linux) REPLY=linux ;; + Darwin) REPLY=darwin ;; + MINGW* | MSYS* | CYGWIN*) REPLY=win32 ;; + *) REPLY=$(printf '%s' "$s" | tr '[:upper:]' '[:lower:]') ;; + esac + ;; + *) REPLY=$ISPC_RUNNER_OS ;; + esac +} + +# Sets REPLY to the host CPU using Node's os.arch() names. +host_arch() { + case "${ISPC_RUNNER_ARCH:-}" in + X64) REPLY=x64 ;; + ARM64) REPLY=arm64 ;; + X86) REPLY=ia32 ;; + ARM) REPLY=arm ;; + '') + local m + m=$(uname -m) || fail "Unable to run uname" + case "$m" in + x86_64 | amd64) REPLY=x64 ;; + aarch64 | arm64) REPLY=arm64 ;; + i?86) REPLY=ia32 ;; + arm*) REPLY=arm ;; + *) REPLY=$m ;; + esac + ;; + *) REPLY=$ISPC_RUNNER_ARCH ;; + esac +} + +is_windows_host() { + host_platform + [[ $REPLY == win32 ]] +} + +# Converts a native path to one this bash can use (only differs on Windows). +to_unix_path() { + if is_windows_host && command -v cygpath >/dev/null 2>&1; then + REPLY=$(cygpath -u "$1") || fail "Unable to convert path $1" + else + REPLY=$1 + fi +} + +# Sets REPLY to a usable path of the runner's temp directory. +temp_dir() { + to_unix_path "${RUNNER_TEMP:-${TMPDIR:-/tmp}}" + mkdir -p "$REPLY" || fail "Unable to create directory $REPLY" +} + +# Sets REPLY to a file's content without dropping trailing newlines. +read_file() { + local content + content=$(cat "$1" && printf x) || fail "Unable to read $1" + REPLY=${content%x} +} + +# Sets REPLY to the newest tag of the ISPC git repository by commit author date. +latest_version_from_git() { + local prefix='Unable to get latest version from git repository' + require git + local repo + temp_dir + repo="$REPLY/ispc-repo" + rm -rf "$repo" + git clone -q --depth=1 "$ISPC_GIT_URL" "$repo" \ + || fail "$prefix: Command failed: git clone --depth=1 $ISPC_GIT_URL ispc-repo" + + info 'Fetching latest tags from the repository...' + git -C "$repo" fetch -q --tags || fail "$prefix: Command failed: git fetch --tags" + + local tags + tags=$(git -C "$repo" tag) || fail "$prefix: Command failed: git tag" + local tag dated='' + while IFS= read -r tag; do + [[ -z $tag || $tag == trunk-artifacts ]] && continue + local ts + ts=$(git -C "$repo" log -1 --format=%at "$tag") || fail "$prefix: Command failed: git log -1 --format=%ai $tag" + dated+="$ts $tag"$'\n' + done <<<"$tags" + [[ -n $dated ]] || fail "$prefix: No valid releases found." + + # Stable sort keeps `git tag` order for equal dates, like Array.prototype.sort. + local newest + newest=$(printf '%s' "$dated" | sort -s -k1,1nr | head -n 1) || fail "$prefix: Unable to sort tags" + REPLY=${newest#* } + rm -rf "$repo" +} + +# Sets REPLY to the latest released version without the leading "v". +latest_version() { + require curl + require jq + local tmp hdr body code text version + temp_dir + tmp=$REPLY + hdr=$(mktemp "$tmp/ispc-api-headers.XXXXXX") || fail "Unable to create temporary file in $tmp" + body=$(mktemp "$tmp/ispc-api-body.XXXXXX") || fail "Unable to create temporary file in $tmp" + # HTTP/1.1 so the status line carries a reason phrase, which Node's fetch reported as statusText. + code=$(curl -sSL --http1.1 -D "$hdr" -o "$body" -w '%{http_code}' "$ISPC_API_URL") || fail 'fetch failed' + if [[ $code != 200 ]]; then + # The header dump may include proxy and redirect responses; the last status line is the final one. + text=$(tr -d '\r' <"$hdr" | grep '^HTTP/' | tail -n 1 | cut -s -d ' ' -f 3-) || text='' + read_file "$body" + info "Unable to query latest version: $text - $REPLY" + latest_version_from_git + version=$REPLY + else + version=$(jq -r '.tag_name' "$body") || fail "Unable to parse latest release information" + fi + rm -f "$hdr" "$body" + + version=${version#v} + info "Latest ISPC version is '$version'" + REPLY=$version +} + +# Sets VERSION to the tag of the release to install, e.g. v1.23.0. +resolve_version() { + get_input "${INPUT_VERSION:-}" + local version=$REPLY + if [[ -z $version || $version == latest ]]; then + latest_version + version=$REPLY + fi + [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "Invalid version $version" + VERSION="v$version" +} + +# Sets PLATFORM. +resolve_platform() { + get_input "${INPUT_PLATFORM:-}" + local platform=$REPLY + if [[ -z $platform ]]; then + host_platform + case "$REPLY" in + linux) platform=linux ;; + darwin) platform=macOS ;; + win32) platform=windows ;; + *) fail "Platform $REPLY is unsupported for autodetection" ;; + esac + info "Autodetected platform: '$platform'" + fi + case "$platform" in + linux | macOS | windows) ;; + *) fail "Platform $platform not in list of supported platforms: linux,macOS,windows" ;; + esac + PLATFORM=$platform +} + +# Sets ARCH for PLATFORM. An empty ARCH means the archive name has no arch suffix. +resolve_arch() { + get_input "${INPUT_ARCHITECTURE:-}" + local arch=$REPLY + if [[ -z $arch ]]; then + case "$PLATFORM" in + linux) + host_arch + case "$REPLY" in + arm64) arch=aarch64 ;; + # ISPC doesn't have arch suffix for x86_64 linux archives + x64) arch='' ;; + *) fail "Architecture $REPLY is unsupported for autodetection" ;; + esac + ;; + macOS) arch=universal ;; + # ISPC has only x86_64 windows archives + windows) arch='' ;; + esac + info "Autodetected architecture '$arch'" + fi + if [[ $arch == x86_64 && ($PLATFORM == linux || $PLATFORM == windows) ]]; then + # ISPC doesn't have arch suffix for x86_64 linux and windows archives + arch='' + fi + case "$PLATFORM:$arch" in + linux:oneapi | linux:aarch64 | linux: | macOS:x86_64 | macOS:arm64 | macOS:universal | windows:) ;; + *) fail "Platform $PLATFORM does not support arch $arch" ;; + esac + ARCH=$arch +} + +# Waits before the next download attempt. Tests override this to avoid sleeping. +backoff_sleep() { + sleep "$1" +} + +# Downloads $1 to $2 with up to 3 attempts, retrying the same failures as @actions/tool-cache. +download() { + local url=$1 out=$2 attempt=1 max_attempts=3 code rc + while :; do + rm -f "$out" + # Like the old HTTP client's three-minute socket timeout, bound connection + # and stalled-transfer time without limiting the duration of a healthy download. + if code=$(curl -sSL --retry 0 --connect-timeout 180 --speed-limit 1 --speed-time 180 \ + -w '%{http_code}' -o "$out" "$url"); then rc=0; else rc=$?; fi + if [[ $rc == 0 && $code == 200 ]]; then + return 0 + fi + + local err + if [[ $rc != 0 ]]; then + err="Download failed: curl exited with code $rc" + else + err="Unexpected HTTP response: $code" + fi + rm -f "$out" + if [[ $rc == 0 ]] && ((code < 500)) && [[ $code != 408 && $code != 429 ]]; then + fail "$err" + fi + ((attempt < max_attempts)) || fail "$err" + + info "$err" + local seconds=$((RANDOM % 11 + 10)) + info "Waiting $seconds seconds before trying again" + backoff_sleep "$seconds" + attempt=$((attempt + 1)) + done +} + +# Extracts archive $1 of type $2 (zip|tar.gz) into directory $3. +extract() { + local archive=$1 type=$2 dest=$3 + mkdir -p "$dest" || fail "Unable to create directory $dest" + if [[ $type == tar.gz ]]; then + require tar + tar -xzf "$archive" -C "$dest" || fail "Unable to extract $archive" + elif command -v unzip >/dev/null 2>&1; then + unzip -o -q "$archive" -d "$dest" || fail "Unable to extract $archive" + elif is_windows_host; then + local win_archive win_dest + # Windows PowerShell 5.1 rejects extensionless paths in Expand-Archive. + if [[ $archive != *.zip ]]; then + mv "$archive" "$archive.zip" || fail "Unable to prepare ZIP archive $archive" + archive+=.zip + fi + win_archive=$(cygpath -w "$archive") || fail "Unable to convert path $archive" + win_dest=$(cygpath -w "$dest") || fail "Unable to convert path $dest" + # Pass paths through the environment so they're never parsed as PowerShell code. + # shellcheck disable=SC2016 # $env: is expanded by PowerShell, not bash. + ISPC_ARCHIVE=$win_archive ISPC_DEST=$win_dest powershell -NoLogo -NoProfile -NonInteractive -Command \ + '$ErrorActionPreference = "Stop"; Expand-Archive -LiteralPath $env:ISPC_ARCHIVE -DestinationPath $env:ISPC_DEST -Force' \ + || fail "Unable to extract $archive" + else + require unzip + fi +} + +# Appends $1 to $GITHUB_PATH, like addPath() from @actions/core. +add_path() { + local dir=$1 + if [[ -z ${GITHUB_PATH:-} ]]; then + printf '::add-path::%s\n' "$dir" + return 0 + fi + [[ -f $GITHUB_PATH ]] || fail "Missing file at path: $GITHUB_PATH" + if is_windows_host; then + printf '%s\r\n' "$dir" >>"$GITHUB_PATH" || fail "Unable to write to $GITHUB_PATH" + else + printf '%s\n' "$dir" >>"$GITHUB_PATH" || fail "Unable to write to $GITHUB_PATH" + fi +} + +main() { + # ISPC release naming convention: ispc-${version}-${platform}[.|-]${architecture}.[zip|tar.gz] + resolve_version + resolve_platform + resolve_arch + + local sep=. arch_str='' type=tar.gz + [[ $ARCH == oneapi ]] && sep=- + [[ -n $ARCH ]] && arch_str="$sep$ARCH" + [[ $PLATFORM == windows ]] && type=zip + local name="ispc-$VERSION-$PLATFORM$arch_str" + local url="$ISPC_DOWNLOAD_URL/$VERSION/$name.$type" + local dir=ispc-releases + + info "Downloading ISPC archive $url" + [[ -n ${RUNNER_TEMP:-} ]] || fail 'Expected RUNNER_TEMP to be defined' + require curl + temp_dir + local archive + archive=$(mktemp "$REPLY/XXXXXXXXXXXX") || fail "Unable to create temporary file in $REPLY" + download "$url" "$archive" + extract "$archive" "$type" "$dir" + + # Node's path.resolve() used the physical cwd: symlinks resolved (/var -> + # /private/var on macOS) and, on Windows, long rather than 8.3 names. + local cwd + cwd=$(pwd -P) || fail "Unable to determine the current directory" + local bindir="$cwd/$dir/$name/bin" + if is_windows_host && command -v cygpath >/dev/null 2>&1; then + bindir=$(cygpath -w -l "$bindir") || fail "Unable to convert path $bindir" + fi + info "Adding ISPC binary directory to PATH: $bindir" + add_path "$bindir" +} + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + main +fi diff --git a/test/download-timeout.py b/test/download-timeout.py new file mode 100644 index 0000000..6fc36a8 --- /dev/null +++ b/test/download-timeout.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Test real curl timeouts and retries against a stalled loopback HTTP server.""" + +import http.server +import os +from pathlib import Path +import subprocess +import tempfile +import threading +import unittest + + +PAYLOAD = b"x complete archive\n" +SCRIPT = Path(__file__).resolve().parents[1] / "install.sh" +BASH = r''' +source "$1" +backoff_sleep() { :; } +# Shorten configured timeouts for the test. Missing timeout flags remain +# missing, so the wrapper cannot hide an unbounded-transfer regression. +curl() { + local args=() + while (($#)); do + case "$1" in + --connect-timeout | --speed-time) args+=("$1" 1); shift 2 ;; + *) args+=("$1"); shift ;; + esac + done + command curl "${args[@]}" +} +download "$2" "$3" +''' + + +class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def do_GET(self): + attempts = self.server.attempts + attempts[self.path] = attempts.get(self.path, 0) + 1 + if self.path == "/persistent" or attempts[self.path] == 1: + if self.path == "/partial": + self.send_response(200) + self.send_header("Content-Length", str(len(PAYLOAD))) + self.end_headers() + self.wfile.write(PAYLOAD[:1]) + self.wfile.flush() + self.server.stop.wait(5) + return + self.send_response(200) + self.send_header("Content-Length", str(len(PAYLOAD))) + self.end_headers() + self.wfile.write(PAYLOAD) + + +class DownloadTimeoutTest(unittest.TestCase): + def setUp(self): + self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.server.attempts = {} + self.server.stop = threading.Event() + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + + def tearDown(self): + self.server.stop.set() + self.server.shutdown() + self.server.server_close() + self.thread.join() + + def download(self, path): + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "archive" + url = f"http://127.0.0.1:{self.server.server_port}{path}" + result = subprocess.run( + ["bash", "-c", BASH, "--", str(SCRIPT), url, str(out)], + capture_output=True, text=True, timeout=20, + # Bypass any runner proxy for the loopback fixture. + env={**os.environ, "no_proxy": "127.0.0.1"}, + ) + self.assertIn("curl exited with code 28", result.stdout) + return result, out.read_bytes() if out.exists() else None + + def test_stalled_headers_are_retried(self): + result, content = self.download("/headers") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.server.attempts["/headers"], 2) + self.assertEqual(content, PAYLOAD) + + def test_stalled_body_is_discarded_before_retry(self): + result, content = self.download("/partial") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.server.attempts["/partial"], 2) + self.assertEqual(content, PAYLOAD) + + def test_persistent_stall_stops_after_three_attempts(self): + result, content = self.download("/persistent") + self.assertEqual(result.returncode, 1) + self.assertEqual(self.server.attempts["/persistent"], 3) + self.assertIn("::error::Download failed: curl exited with code 28", result.stdout) + self.assertIsNone(content) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/fixtures/hostile-env.sh b/test/fixtures/hostile-env.sh new file mode 100644 index 0000000..a429c11 --- /dev/null +++ b/test/fixtures/hostile-env.sh @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: Apache-2.0 +# Sourced by bash only if the action fails to clear BASH_ENV/ENV. +echo "::error::hostile-env.sh was sourced by the action" +# shellcheck disable=SC2123 # Breaking PATH is the point. +PATH=/nonexistent +cd / +exit 3 diff --git a/test/parity.sh b/test/parity.sh new file mode 100644 index 0000000..e275db5 --- /dev/null +++ b/test/parity.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# +# Differential test: runs the Node implementation (build/main.cjs) and +# install.sh on the same inputs and compares what users can observe +# (exit code, annotations, GITHUB_PATH, logs, extracted files, error escaping). +# +# Usage: bash test/parity.sh [full|host] +# full every case; explicit platform/arch cases don't depend on the host (default) +# host only cases whose result depends on the host OS/arch +# +# Environment: +# OLD_IMPL path to the Node bundle (default: build/main.cjs) +# NODE node binary (default: node) +# KEEP set to keep the scratch directory + +# Inputs with shell metacharacters are literal on purpose. +# shellcheck disable=SC2016 + +set -uo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +MODE=${1:-full} +OLD_IMPL=${OLD_IMPL:-$ROOT/build/main.cjs} +NEW_IMPL=$ROOT/install.sh +NODE=${NODE:-node} +WORK=$(mktemp -d) +[[ -n ${KEEP:-} ]] || trap 'rm -rf "$WORK"' EXIT + +# install.sh autodetects from runner.os/runner.arch; outside Actions it uses uname. +export ISPC_RUNNER_OS=${RUNNER_OS:-} ISPC_RUNNER_ARCH=${RUNNER_ARCH:-} + +case "$(uname -s)" in + MINGW* | MSYS* | CYGWIN*) HOST=windows ;; + Darwin) HOST=macOS ;; + *) HOST=linux ;; +esac + +passed=0 +failed=0 + +# Runs implementation $1 (old|new) in $WORK/ws, then moves its results to $WORK/$1. +run_impl() { + local impl=$1 + rm -rf "${WORK:?}/ws" "${WORK:?}/rt" "${WORK:?}/$impl" + mkdir -p "$WORK/ws" "$WORK/rt" "$WORK/$impl" + : >"$WORK/github_path" + local rc + ( + cd "$WORK/ws" || exit 99 + export RUNNER_TEMP="$WORK/rt" GITHUB_PATH="$WORK/github_path" + export INPUT_VERSION=$CASE_VERSION INPUT_PLATFORM=$CASE_PLATFORM INPUT_ARCHITECTURE=$CASE_ARCH + if [[ $impl == old ]]; then + "$NODE" "$OLD_IMPL" + else + bash --noprofile --norc "$NEW_IMPL" + fi + ) >"$WORK/$impl/stdout" 2>"$WORK/$impl/stderr" + rc=$? + printf '%s\n' "$rc" >"$WORK/$impl/exit-code" + # ::debug:: lines only show with step debug logging and aren't part of the contract. + grep -a '^::' "$WORK/$impl/stdout" | grep -av '^::debug::' >"$WORK/$impl/workflow-commands" + cp "$WORK/github_path" "$WORK/$impl/github-path" + grep -a '^Downloading ISPC archive ' "$WORK/$impl/stdout" >"$WORK/$impl/url" + grep -aE "^(Autodetected |Latest ISPC version is |Adding ISPC binary directory to PATH: )" \ + "$WORK/$impl/stdout" >"$WORK/$impl/info" + ( + cd "$WORK/ws" || exit 99 + [[ -d ispc-releases ]] || exit 0 + find ispc-releases \( -type f -o -type l \) | LC_ALL=C sort | while IFS= read -r f; do + if [[ -L $f ]]; then + printf '%s link\n' "$f" + else + x=- + [[ $HOST != windows && -x $f ]] && x=x + printf '%s %s %s\n' "$f" "$(wc -c <"$f" | tr -d ' ')" "$x" + fi + done + ) >"$WORK/$impl/tree" + (cd "$WORK/ws" && find . -path ./ispc-releases -prune -o -print | LC_ALL=C sort) >"$WORK/$impl/workspace" + # Run the installed compiler when it can execute on this host. + : >"$WORK/$impl/ispc-version" + if [[ $rc == 0 && ${CASE_PLATFORM:-$HOST} == "$HOST" ]]; then + local bindir + bindir=$(tr -d '\r' <"$WORK/github_path" | tail -n 1) + [[ $HOST == windows ]] && bindir=$(cygpath -u "$bindir") + "$bindir/ispc" --version >"$WORK/$impl/ispc-version" 2>&1 || echo "exit $?" >>"$WORK/$impl/ispc-version" + fi +} + +# check_case NAME VERSION PLATFORM ARCH [--expect-fail] +check_case() { + local name=$1 + CASE_VERSION=$2 CASE_PLATFORM=$3 CASE_ARCH=$4 + local expect=${5:-} + run_impl old + run_impl new + + local diffs='' f + for f in exit-code workflow-commands github-path url info tree workspace ispc-version; do + cmp -s "$WORK/old/$f" "$WORK/new/$f" || diffs+=" $f" + done + local rc + rc=$(cat "$WORK/new/exit-code") + if [[ $expect == --expect-fail && $rc == 0 ]]; then + diffs+=" expected-failure" + elif [[ $expect != --expect-fail && $rc != 0 ]]; then + diffs+=" expected-success" + fi + # A failure emits exactly one workflow command. + if [[ $rc != 0 && $(wc -l <"$WORK/new/workflow-commands") -ne 1 ]]; then + diffs+=" one-workflow-command" + fi + + if [[ -z $diffs ]]; then + passed=$((passed + 1)) + printf 'ok %s\n' "$name" + else + failed=$((failed + 1)) + printf 'FAIL %s:%s\n' "$name" "$diffs" + for f in $diffs; do + [[ -f $WORK/old/$f ]] || continue + diff -u --label "old/$f" --label "new/$f" "$WORK/old/$f" "$WORK/new/$f" | head -n 40 | sed 's/^/ /' + done + printf ' --- old stdout\n' + sed 's/^/ /' "$WORK/old/stdout" | head -n 20 + printf ' --- new stdout\n' + sed 's/^/ /' "$WORK/new/stdout" | head -n 20 + printf ' --- new stderr\n' + sed 's/^/ /' "$WORK/new/stderr" | head -n 20 + fi +} + +# --- host-dependent cases (autodetection, cross-platform extraction) ------- + +check_case "autodetect, latest" '' '' '' +check_case "autodetect, 1.23.0" 1.23.0 '' '' +check_case "host platform, autodetect arch" 1.23.0 "$HOST" '' +case "$HOST" in + macOS) check_case "linux archive on macOS" 1.23.0 linux '' ;; + linux) check_case "windows archive on Linux" 1.23.0 windows '' ;; +esac + +if [[ $MODE == full ]]; then + # --- version ------------------------------------------------------------- + check_case "version latest" latest linux '' + check_case "version ' latest '" ' latest ' linux '' + check_case "version ' 1.23.0 '" ' 1.23.0 ' linux '' + # Use validation failures to compare Unicode trimming without extra downloads. + check_case "trim NBSP in version" $'\xc2\xa01.23.0\xc2\xa0' foo '' --expect-fail + check_case "trim BOM in version" $'\xef\xbb\xbf1.23.0\xef\xbb\xbf' foo '' --expect-fail + check_case "trim Unicode in platform" 1.23.0 $'\xc2\xa0foo\xef\xbb\xbf' '' --expect-fail + check_case "trim Unicode in arch" 1.23.0 linux $'\xef\xbb\xbffoo\xc2\xa0' --expect-fail + for v in v1.23.0 1.23 1.23.0rc1; do + check_case "version '$v'" "$v" linux '' --expect-fail + done + check_case "version 9.9.9 (404)" 9.9.9 linux '' --expect-fail + + # --- platform ------------------------------------------------------------ + for p in macos freebsd; do + check_case "platform '$p'" 1.23.0 "$p" '' --expect-fail + done + check_case "platform macOS, autodetect arch" 1.23.0 macOS '' + check_case "platform windows, autodetect arch" 1.23.0 windows '' + + # --- architecture -------------------------------------------------------- + for combo in linux:oneapi linux:aarch64 linux:x86_64 macOS:x86_64 macOS:arm64 macOS:universal windows:x86_64; do + check_case "$combo" 1.23.0 "${combo%%:*}" "${combo#*:}" + done + for combo in windows:aarch64 macOS:oneapi linux:arm64 linux:foo; do + check_case "$combo" 1.23.0 "${combo%%:*}" "${combo#*:}" --expect-fail + done + + # --- shell metacharacters and escaping --------------------------------- + for v in '$(id)' '1.2.3;id' "1'2" '1"2' '`id`'; do + check_case "version $v" "$v" linux '' --expect-fail + done + check_case "platform \$(touch pwned)" 1.23.0 '$(touch pwned)' '' --expect-fail + check_case "arch ;touch pwned" 1.23.0 linux ';touch pwned' --expect-fail + check_case "escaping %" '100%' linux '' --expect-fail + check_case "escaping literal %0A" '1%0A2' linux '' --expect-fail + check_case "escaping CR/LF" $'1\r2\n3' linux '' --expect-fail + check_case "escaping injected command" $'1\n::warning::injected' linux '' --expect-fail + check_case "escaping in platform" 1.23.0 $'a%b\r\n::warning::x' '' --expect-fail +fi + +printf '\n%d passed, %d failed\n' "$passed" "$failed" +((failed == 0)) diff --git a/test/unit.sh b/test/unit.sh new file mode 100644 index 0000000..a9755a0 --- /dev/null +++ b/test/unit.sh @@ -0,0 +1,394 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# +# Unit tests for install.sh. They need no network: curl is replaced by a stub +# that replays a scripted sequence of responses and serves local fixtures. +# +# Usage: bash test/unit.sh +# +# Keep this compatible with bash 3.2 (macOS /bin/bash), like install.sh. + +# The script is sourced from a variable path, test inputs are literal on +# purpose, and each case runs in a subshell that sets its own environment. +# shellcheck disable=SC1090,SC1091,SC2016,SC2030,SC2031,SC2317,SC2329 + +set -uo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +SCRIPT="$ROOT/install.sh" +# Physical path: install.sh reports the resolved cwd, like Node did. +WORK=$(cd "$(mktemp -d)" && pwd -P) +trap 'rm -rf "$WORK"' EXIT + +passed=0 +failed=0 + +pass() { + passed=$((passed + 1)) + printf 'ok %s\n' "$1" +} + +flunk() { + failed=$((failed + 1)) + printf 'FAIL %s\n' "$1" + shift + printf ' %s\n' "$@" +} + +# check NAME EXPECTED ACTUAL +check() { + if [[ $2 == "$3" ]]; then + pass "$1" + else + flunk "$1" "expected: $(printf '%q' "$2")" "actual: $(printf '%q' "$3")" + fi +} + +# Replays the next line of $STUB_SEQ: " [fixture file]". +stub_curl() { + local out='' hdr='' url='' + while (($#)); do + case "$1" in + -o) out=$2; shift ;; + -D) hdr=$2; shift ;; + -w) shift ;; + --retry | --connect-timeout | --speed-limit | --speed-time) shift ;; + -*) ;; + *) url=$1 ;; + esac + shift + done + printf '%s\n' "$url" >>"$STUB_LOG" + # Pure bash, so it also works with the limited PATHs used below. An + # exhausted sequence answers 404. + local lines=() line rc code fixture + while IFS= read -r line; do lines+=("$line"); done <"$STUB_SEQ" + read -r rc code fixture <<<"${lines[0]:-0 404}" + if ((${#lines[@]} > 1)); then printf '%s\n' "${lines[@]:1}" >"$STUB_SEQ"; else : >"$STUB_SEQ"; fi + [[ -n $hdr ]] && printf 'HTTP/1.1 200 Connection established\r\n\r\nHTTP/1.1 %s Reason For %s\r\n\r\n' "$code" "$code" >"$hdr" + if [[ -n $out ]]; then + if [[ -n ${fixture:-} ]]; then + cat "$fixture" >"$out" + else + printf 'partial' >"$out" + fi + fi + printf '%s' "$code" + return "$rc" +} + +# Runs install.sh's main in a fresh workspace with the curl stub replaying +# PENDING_SEQ. Arguments are VAR=value assignments. Sets OUT, RC and CASE_DIR. +PENDING_SEQ=() +run_main() { + CASE_DIR=$(mktemp -d "$WORK/case.XXXXXX") + printf '%s\n' ${PENDING_SEQ[@]+"${PENDING_SEQ[@]}"} >"$CASE_DIR/seq" + PENDING_SEQ=() + mkdir -p "$CASE_DIR/ws" "$CASE_DIR/rt" + : >"$CASE_DIR/github_path" + : >"$CASE_DIR/curl.log" + : >"$CASE_DIR/sleep.log" + OUT=$( + cd "$CASE_DIR/ws" || exit 99 + export RUNNER_TEMP="$CASE_DIR/rt" GITHUB_PATH="$CASE_DIR/github_path" + export ISPC_RUNNER_OS=Linux ISPC_RUNNER_ARCH=X64 + export STUB_SEQ="$CASE_DIR/seq" STUB_LOG="$CASE_DIR/curl.log" SLEEP_LOG="$CASE_DIR/sleep.log" + unset INPUT_VERSION INPUT_PLATFORM INPUT_ARCHITECTURE + for a in "$@"; do export "${a?}"; done + source "$SCRIPT" + curl() { stub_curl "$@"; } + backoff_sleep() { printf '%s\n' "$1" >>"$SLEEP_LOG"; } + main + ) + RC=$? +} + +count_lines() { + local n + n=$(wc -l <"$1") + printf '%s' "$((n))" +} + +error_line() { + printf '%s\n' "$OUT" | grep '^::' || true +} + +# Fixtures: fake release archives with an executable bin/ispc. +make_fixture() { + local name=$1 type=$2 + local src="$WORK/src/$name" + mkdir -p "$src/bin" + printf '#!/bin/sh\necho fake ispc\n' >"$src/bin/ispc" + chmod +x "$src/bin/ispc" + if [[ $type == tar.gz ]]; then + tar -czf "$WORK/$name.tar.gz" -C "$WORK/src" "$name" + else + (cd "$WORK/src" && zip -q -r "$WORK/$name.zip" "$name") + fi +} +make_fixture ispc-v1.23.0-linux tar.gz +TGZ="$WORK/ispc-v1.23.0-linux.tar.gz" +if command -v zip >/dev/null 2>&1; then + make_fixture ispc-v1.23.0-windows zip + ZIP="$WORK/ispc-v1.23.0-windows.zip" +fi + +URL_123=https://github.com/ispc/ispc/releases/download/v1.23.0/ispc-v1.23.0-linux.tar.gz + +# --- error escaping -------------------------------------------------------- + +msg_out=$(source "$SCRIPT"; fail $'100% done\r\nnext %0A line\n::warning::injected') +check "fail escapes %, CR and LF" '::error::100%25 done%0D%0Anext %250A line%0A::warning::injected' "$msg_out" +check "fail emits exactly one line" 1 "$(printf '%s\n' "$msg_out" | wc -l | tr -d ' ')" +msg_out=$(source "$SCRIPT"; fail '%s %d \x41') +check "fail doesn't interpret format strings" '::error::%25s %25d \x41' "$msg_out" +(source "$SCRIPT"; fail x >/dev/null) +check "fail exits 1" 1 $? + +# --- input trimming -------------------------------------------------------- + +trim_out=$(source "$SCRIPT"; get_input $' \t 1.23.0 \n'; printf '[%s]' "$REPLY") +check "get_input trims whitespace" '[1.23.0]' "$trim_out" + +unicode_space=$'\xc2\xa0\xef\xbb\xbf\xe1\x9a\x80\xe2\x80\x83\xe2\x80\xa8\xe2\x80\xa9\xe2\x80\xaf\xe2\x81\x9f\xe3\x80\x80' +for trim_locale in C "${LC_ALL:-${LANG:-C}}"; do + trim_out=$(export LC_ALL=$trim_locale; source "$SCRIPT"; get_input "$unicode_space 1.23.0 $unicode_space"; printf '[%s]' "$REPLY") + check "get_input trims Unicode whitespace in $trim_locale" '[1.23.0]' "$trim_out" + trim_out=$(export LC_ALL=$trim_locale; source "$SCRIPT"; get_input "$unicode_space"; printf '[%s]' "$REPLY") + check "get_input trims Unicode-only input in $trim_locale" '[]' "$trim_out" +done +for non_space in $'\xc2\x85' $'\xe1\xa0\x8e' $'\xe2\x80\x8b'; do + trim_out=$(source "$SCRIPT"; get_input "$non_space 1.23.0 $non_space"; printf '%s' "$REPLY") + check "get_input preserves non-JavaScript whitespace $(printf '%q' "$non_space")" "$non_space 1.23.0 $non_space" "$trim_out" +done +trim_out=$(source "$SCRIPT"; get_input " ${unicode_space}1${unicode_space}2 "; printf '%s' "$REPLY") +check "get_input preserves internal Unicode whitespace" "1${unicode_space}2" "$trim_out" + +PENDING_SEQ=("0 200 $TGZ") +run_main "INPUT_VERSION=${unicode_space}1.23.0${unicode_space}" \ + "INPUT_PLATFORM=${unicode_space}linux${unicode_space}" "INPUT_ARCHITECTURE=${unicode_space}x86_64${unicode_space}" +check "all inputs accept surrounding Unicode whitespace" "0|$URL_123" "$RC|$(head -n 1 "$CASE_DIR/curl.log")" + +# --- validation (no network) ----------------------------------------------- + +for v in v1.23.0 1.23 1.23.0rc1 '$(id)' '1.2.3;id' "1.2.3'" '1.2.3"' $'1.2.3\nid'; do + run_main "INPUT_VERSION=$v" + esc=${v//$'\n'/%0A} + check "version '$v' rejected" "1|::error::Invalid version $esc|0" "$RC|$(error_line)|$(count_lines "$CASE_DIR/curl.log")" +done + +for p in macos freebsd Linux '$(id)'; do + run_main INPUT_VERSION=1.23.0 "INPUT_PLATFORM=$p" + check "platform '$p' rejected" "1|::error::Platform $p not in list of supported platforms: linux,macOS,windows" "$RC|$(error_line)" +done + +for combo in windows:aarch64 macOS:oneapi linux:arm64 linux:foo macOS:'$(id)'; do + p=${combo%%:*} a=${combo#*:} + run_main INPUT_VERSION=1.23.0 "INPUT_PLATFORM=$p" "INPUT_ARCHITECTURE=$a" + check "arch '$a' on $p rejected" "1|::error::Platform $p does not support arch $a" "$RC|$(error_line)" +done + +run_main INPUT_VERSION=1.23.0 ISPC_RUNNER_OS=FreeBSD +check "unknown runner OS" "1|::error::Platform FreeBSD is unsupported for autodetection" "$RC|$(error_line)" +run_main INPUT_VERSION=1.23.0 ISPC_RUNNER_ARCH=ARM +check "unsupported linux runner arch" "1|::error::Architecture arm is unsupported for autodetection" "$RC|$(error_line)" +run_main INPUT_VERSION=1.23.0 ISPC_RUNNER_ARCH=X86 +check "ia32 linux runner arch" "1|::error::Architecture ia32 is unsupported for autodetection" "$RC|$(error_line)" + +# --- URL construction ------------------------------------------------------ + +base=https://github.com/ispc/ispc/releases/download/v1.23.0 +url_case() { + local expected=$1 log=$2 + shift 2 + PENDING_SEQ=('0 404') + run_main INPUT_VERSION=1.23.0 "$@" + check "url for $*" "$expected|$log" "$(head -n 1 "$CASE_DIR/curl.log")|$(printf '%s\n' "$OUT" | grep -E '^Autodetected' | tr '\n' '|')" +} +url_case "$base/ispc-v1.23.0-linux.tar.gz" "Autodetected platform: 'linux'|Autodetected architecture ''|" +url_case "$base/ispc-v1.23.0-linux.aarch64.tar.gz" "Autodetected platform: 'linux'|Autodetected architecture 'aarch64'|" ISPC_RUNNER_ARCH=ARM64 +url_case "$base/ispc-v1.23.0-macOS.universal.tar.gz" "Autodetected platform: 'macOS'|Autodetected architecture 'universal'|" ISPC_RUNNER_OS=macOS ISPC_RUNNER_ARCH=ARM64 +url_case "$base/ispc-v1.23.0-macOS.universal.tar.gz" "Autodetected architecture 'universal'|" INPUT_PLATFORM=macOS +url_case "$base/ispc-v1.23.0-windows.zip" "Autodetected platform: 'windows'|Autodetected architecture ''|" ISPC_RUNNER_OS=Windows +url_case "$base/ispc-v1.23.0-linux-oneapi.tar.gz" "" INPUT_PLATFORM=linux INPUT_ARCHITECTURE=oneapi +url_case "$base/ispc-v1.23.0-linux.tar.gz" "Autodetected platform: 'linux'|" INPUT_ARCHITECTURE=x86_64 +url_case "$base/ispc-v1.23.0-windows.zip" "" INPUT_PLATFORM=windows INPUT_ARCHITECTURE=x86_64 +url_case "$base/ispc-v1.23.0-macOS.x86_64.tar.gz" "" INPUT_PLATFORM=macOS INPUT_ARCHITECTURE=x86_64 +url_case "$base/ispc-v1.23.0-macOS.arm64.tar.gz" "" "INPUT_PLATFORM= macOS " "INPUT_ARCHITECTURE= arm64 " + +# --- download retries ------------------------------------------------------ + +retry_case() { + local name=$1 rc=$2 attempts=$3 err=$4 + shift 4 + PENDING_SEQ=("$@") + run_main INPUT_VERSION=1.23.0 + check "$name" "$rc|$attempts|$err" "$RC|$(count_lines "$CASE_DIR/curl.log")|$(error_line)" +} +retry_case "503/503/200 succeeds" 0 3 "" '0 503' '0 503' "0 200 $TGZ" +retry_case "501/501/200 succeeds" 0 3 "" '0 501' '0 501' "0 200 $TGZ" +retry_case "408/408/200 succeeds" 0 3 "" '0 408' '0 408' "0 200 $TGZ" +retry_case "429/429/200 succeeds" 0 3 "" '0 429' '0 429' "0 200 $TGZ" +retry_case "persistent 503 stops after 3" 1 3 "::error::Unexpected HTTP response: 503" '0 503' '0 503' '0 503' "0 200 $TGZ" +retry_case "404 isn't retried" 1 1 "::error::Unexpected HTTP response: 404" '0 404' "0 200 $TGZ" +retry_case "204 isn't retried" 1 1 "::error::Unexpected HTTP response: 204" '0 204' "0 200 $TGZ" +retry_case "DNS failure is retried" 0 2 "" '6 000' "0 200 $TGZ" +retry_case "connection refusal is retried" 0 2 "" '7 000' "0 200 $TGZ" +retry_case "stalled transfer timeout is retried" 0 2 "" '28 200' "0 200 $TGZ" +retry_case "interrupted transfer is retried" 0 3 "" '18 200' '56 200' "0 200 $TGZ" +retry_case "persistent transport failure stops after 3" 1 3 "::error::Download failed: curl exited with code 7" '7 000' '7 000' '7 000' +retry_case "persistent timeout stops after 3" 1 3 "::error::Download failed: curl exited with code 28" '28 000' '28 000' '28 000' + +PENDING_SEQ=('18 200' '0 503' "0 200 $TGZ") +run_main INPUT_VERSION=1.23.0 +archive=$(find "$CASE_DIR/rt" -type f) +check "partial download is replaced by the complete archive" "0|1" "$RC|$(cmp -s "$archive" "$TGZ" && echo 1)" +bad_sleep=$(awk '$1 < 10 || $1 > 20 || $1 != int($1)' "$CASE_DIR/sleep.log") +check "backoff waits twice, 10-20 s each" "2|" "$(count_lines "$CASE_DIR/sleep.log")|$bad_sleep" +check "retry logs the reason and the wait" 2 "$(printf '%s\n' "$OUT" | grep -c '^Waiting [0-9]* seconds before trying again$')" + +# Over many draws every backoff stays within 10-20 s. +draws=$(source "$SCRIPT"; for _ in $(seq 200); do printf '%s\n' $((RANDOM % 11 + 10)); done | sort -n | uniq | tr '\n' ' ') +check "backoff range covers exactly 10..20" "10 11 12 13 14 15 16 17 18 19 20 " "$draws" + +# --- successful install ---------------------------------------------------- + +PENDING_SEQ=("0 200 $TGZ") +run_main INPUT_VERSION=1.23.0 +bindir="$CASE_DIR/ws/ispc-releases/ispc-v1.23.0-linux/bin" +check "install succeeds" 0 "$RC" +check "GITHUB_PATH gets the bin dir with LF" "$bindir"$'\nx' "$(cat "$CASE_DIR/github_path"; printf x)" +check "ispc is extracted and executable" "fake ispc" "$("$bindir/ispc")" +check "log lines" "Autodetected platform: 'linux'|Autodetected architecture ''|Downloading ISPC archive $URL_123|Adding ISPC binary directory to PATH: $bindir|" "$(printf '%s\n' "$OUT" | tr '\n' '|')" +check "workspace has only ispc-releases" "ispc-releases" "$(ls -A "$CASE_DIR/ws")" + +# Second install over the first overwrites files. +OUT=$( + cd "$CASE_DIR/ws" || exit 99 + printf '%s\n' "0 200 $TGZ" >"$CASE_DIR/seq" + export RUNNER_TEMP="$CASE_DIR/rt" GITHUB_PATH="$CASE_DIR/github_path" INPUT_VERSION=1.23.0 + export ISPC_RUNNER_OS=Linux ISPC_RUNNER_ARCH=X64 STUB_SEQ="$CASE_DIR/seq" STUB_LOG="$CASE_DIR/curl.log" + source "$SCRIPT" + curl() { stub_curl "$@"; } + main +) +check "second install into the same workspace succeeds" "0|2" "$?|$(count_lines "$CASE_DIR/github_path")" + +if [[ -n ${ZIP:-} ]] && command -v unzip >/dev/null 2>&1; then + PENDING_SEQ=("0 200 $ZIP") + run_main INPUT_VERSION=1.23.0 INPUT_PLATFORM=windows + check "windows zip extracts with unzip on a non-Windows host" "0|fake ispc" \ + "$RC|$("$CASE_DIR/ws/ispc-releases/ispc-v1.23.0-windows/bin/ispc")" +fi + +PENDING_SEQ=("0 200 $TGZ") +run_main INPUT_VERSION=1.23.0 RUNNER_TEMP= +check "missing RUNNER_TEMP" "1|::error::Expected RUNNER_TEMP to be defined" "$RC|$(error_line)" + +PENDING_SEQ=("0 200 $TGZ") +run_main INPUT_VERSION=1.23.0 GITHUB_PATH= +check "no GITHUB_PATH falls back to add-path command" "0|::add-path::$CASE_DIR/ws/ispc-releases/ispc-v1.23.0-linux/bin" "$RC|$(error_line)" + +# --- latest version via the API -------------------------------------------- + +printf '{"tag_name": "v1.31.0"}' >"$WORK/latest.json" +PENDING_SEQ=("0 200 $WORK/latest.json" "0 200 $TGZ") +run_main INPUT_VERSION=' latest ' +check "latest via API" "Latest ISPC version is '1.31.0'|https://github.com/ispc/ispc/releases/download/v1.31.0/ispc-v1.31.0-linux.tar.gz" \ + "$(printf '%s\n' "$OUT" | grep '^Latest')|$(sed -n 2p "$CASE_DIR/curl.log")" + +PENDING_SEQ=("0 200 $WORK/latest.json" "0 404") +run_main INPUT_VERSION= +check "empty version means latest" "Latest ISPC version is '1.31.0'" "$(printf '%s\n' "$OUT" | grep '^Latest')" + +PENDING_SEQ=('7 000') +run_main INPUT_VERSION=latest +check "API transport failure" "1|::error::fetch failed" "$RC|$(error_line)" + +# --- git fallback ---------------------------------------------------------- + +make_repo() { + # Annotated tags need a tagger identity, which CI runners don't configure. + export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t + local repo="$WORK/upstream" + rm -rf "$repo" + git init -q "$repo" + local n=0 + commit_at() { + n=$((n + 1)) + GIT_AUTHOR_DATE="$1" GIT_COMMITTER_DATE="$1" git -C "$repo" -c user.name=t -c user.email=t@t \ + commit -q --allow-empty -m "c$n" + } + commit_at '2024-01-01T00:00:00+0000' + git -C "$repo" tag v1.22.0 + commit_at '2024-02-01T00:00:00+0000' + git -C "$repo" tag -a -m rel v1.23.0 + # Same instant as v1.23.0, different time zone: ties keep alphabetical tag order. + commit_at '2024-02-01T05:00:00+0500' + git -C "$repo" tag v1.23.1 + commit_at '2024-03-01T00:00:00+0000' + git -C "$repo" tag trunk-artifacts + commit_at '2023-12-01T00:00:00+0000' + git -C "$repo" tag v1.24.0-older-date + printf 'file://%s' "$repo" +} +if command -v git >/dev/null 2>&1; then + repo_url=$(make_repo) + PENDING_SEQ=("0 403 $WORK/latest.json") + run_main INPUT_VERSION=latest "ISPC_GIT_URL=$repo_url" + check "git fallback picks newest tag, skips trunk-artifacts, keeps tie order" \ + "Unable to query latest version: Reason For 403 - {\"tag_name\": \"v1.31.0\"}|Fetching latest tags from the repository...|Latest ISPC version is '1.23.0'" \ + "$(printf '%s\n' "$OUT" | grep -E '^(Unable|Fetching|Latest)' | tr '\n' '|' | sed 's/|$//')" + check "git fallback leaves no clone behind" "" "$(ls -A "$CASE_DIR/ws"; find "$CASE_DIR/rt" -name ispc-repo)" + + PENDING_SEQ=("0 403 $WORK/latest.json") + run_main INPUT_VERSION=latest "ISPC_GIT_URL=file://$WORK/does-not-exist" + check "git fallback clone failure" \ + "1|::error::Unable to get latest version from git repository: Command failed: git clone --depth=1 file://$WORK/does-not-exist ispc-repo" \ + "$RC|$(error_line)" +fi + +# --- lazy tool checks ------------------------------------------------------ + +# A PATH with only the listed tools. +limited_path() { + local dir="$WORK/bin-$1" + shift + mkdir -p "$dir" + local t + for t in "$@"; do + ln -sf "$(command -v "$t")" "$dir/$t" + done + printf '%s' "$dir" +} +BASE_TOOLS=(bash cat mkdir mktemp rm tr cut grep tail head sort uname gzip) +nojq=$(limited_path nojq "${BASE_TOOLS[@]}" tar) +PENDING_SEQ=("0 200 $TGZ") +run_main INPUT_VERSION=1.23.0 "PATH=$nojq" +check "pinned version needs neither jq nor git" "0|" "$RC|$(error_line)" + +PENDING_SEQ=("0 200 $WORK/latest.json") +run_main INPUT_VERSION=latest "PATH=$nojq" +check "latest without jq fails clearly" "1|::error::install-ispc-action requires 'jq' on PATH" "$RC|$(error_line)" + +notar=$(limited_path notar "${BASE_TOOLS[@]}") +PENDING_SEQ=("0 200 $TGZ") +run_main INPUT_VERSION=1.23.0 "PATH=$notar" +check "missing tar fails clearly" "1|::error::install-ispc-action requires 'tar' on PATH" "$RC|$(error_line)" + +PENDING_SEQ=("0 200 $TGZ") +run_main INPUT_VERSION=1.23.0 INPUT_PLATFORM=windows "PATH=$notar" +check "zip without unzip on a non-Windows host fails clearly" "1|::error::install-ispc-action requires 'unzip' on PATH" "$RC|$(error_line)" + +nocurl=$(limited_path nocurl "${BASE_TOOLS[@]}" tar) +( + cd "$WORK" || exit 99 + export PATH=$nocurl RUNNER_TEMP="$WORK/rt-nocurl" INPUT_VERSION=1.23.0 ISPC_RUNNER_OS=Linux ISPC_RUNNER_ARCH=X64 + bash "$SCRIPT" +) >"$WORK/nocurl.out" +check "missing curl fails clearly" "1|::error::install-ispc-action requires 'curl' on PATH" "$?|$(grep '^::' "$WORK/nocurl.out")" + +# --- summary --------------------------------------------------------------- + +printf '\n%d passed, %d failed\n' "$passed" "$failed" +((failed == 0)) diff --git a/test/windows-zip.sh b/test/windows-zip.sh new file mode 100644 index 0000000..007fcc7 --- /dev/null +++ b/test/windows-zip.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# +# Exercises the real Windows PowerShell 5.1 fallback with unzip unavailable. +# shellcheck disable=SC1090,SC1091 + +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT +export ISPC_RUNNER_OS=Windows +source "$ROOT/install.sh" + +mkdir -p "$WORK/src/bin" +printf 'fixture compiler\n' >"$WORK/src/bin/ispc.exe" +export ISPC_SOURCE ISPC_ARCHIVE +ISPC_SOURCE=$(cygpath -w "$WORK/src") +ISPC_ARCHIVE=$(cygpath -w "$WORK/release") +# shellcheck disable=SC2016 +powershell -NoLogo -NoProfile -NonInteractive -Command \ + '$ErrorActionPreference = "Stop"; Add-Type -AssemblyName System.IO.Compression.FileSystem; [IO.Compression.ZipFile]::CreateFromDirectory($env:ISPC_SOURCE, $env:ISPC_ARCHIVE)' + +# Hide unzip only from tool discovery; keep the real cygpath and PowerShell. +command() { + if [[ ${1:-} == -v && ${2:-} == unzip ]]; then return 1; fi + builtin command "$@" +} +if command -v unzip >/dev/null 2>&1; then + echo 'unzip was not hidden' + exit 1 +fi + +dest="$WORK/destination with space's" +extract "$WORK/release" zip "$dest" +[[ $(cat "$dest/bin/ispc.exe") == 'fixture compiler' ]] +[[ -f $WORK/release.zip ]] + +printf 'stale compiler\n' >"$dest/bin/ispc.exe" +extract "$WORK/release.zip" zip "$dest" +[[ $(cat "$dest/bin/ispc.exe") == 'fixture compiler' ]] +echo 'PowerShell extracts extensionless ZIP downloads and overwrites existing files'