diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff3b7dd..5baeccd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,10 @@ jobs: persist-credentials: false - name: Lint shell scripts - run: shellcheck install.sh run.sh + run: shellcheck install.sh run.sh tests/install.test.sh tests/mocks/* + + - name: Test installer verification + run: bash tests/install.test.sh - name: Validate action.yml run: | diff --git a/README.md b/README.md index a1337e1..ab32c2b 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,8 @@ Set `dry_run: true` to preview what the action would do without touching Linear. Each release of this action defaults to a specific [Linear Release CLI](https://github.com/linear/linear-release) version. Pinning the action — whether by tag (`@v0`) or commit SHA — also pins the CLI. Set `cli_version` to override. +CLI releases through `v0.16.0` predate artifact verification and remain available through a legacy compatibility path. For newer releases, the action requires an immutable GitHub release and verifies the downloaded executable against the release's `checksums.txt` before making it executable. Missing, malformed, or mismatched integrity metadata causes installation to fail. + ## Troubleshooting **"Unsupported OS" or "Unsupported arch" error** diff --git a/action.yml b/action.yml index c309e55..dea3079 100644 --- a/action.yml +++ b/action.yml @@ -62,7 +62,7 @@ inputs: required: false default: v0.16.0 github_token: - description: GitHub token used to authenticate release downloads through the GitHub CLI. Defaults to the workflow's automatic token. Pass a personal access token or GitHub App token when downloading from another repository or when you need higher rate limits than the default token provides. + description: GitHub token used to authenticate release metadata and downloads. Defaults to the workflow's automatic token, which avoids the lower anonymous API rate limit. required: false default: ${{ github.token }} diff --git a/install.sh b/install.sh index daa8cb7..458844f 100644 --- a/install.sh +++ b/install.sh @@ -2,8 +2,56 @@ set -euo pipefail CLI_VERSION="${CLI_VERSION:-latest}" +SCRIPT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ACTION_PATH="${GITHUB_ACTION_PATH:-$(pwd)}" BIN_PATH="${ACTION_PATH}/linear-release" +LEGACY_VERSIONS_PATH="${SCRIPT_PATH}/legacy-versions.txt" +RELEASES_API="https://api.github.com/repos/linear/linear-release/releases" + +error() { + echo "::error::$*" >&2 +} + +is_legacy_version() { + grep -Fqx -- "$1" "$LEGACY_VERSIONS_PATH" +} + +fetch_release() { + local endpoint="$1" + local api_curl_args=( + -fsSL + -H "Accept: application/vnd.github+json" + -H "X-GitHub-Api-Version: 2026-03-10" + ) + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + api_curl_args+=(-H "Authorization: Bearer ${GITHUB_TOKEN}") + fi + curl "${api_curl_args[@]}" "${RELEASES_API}/${endpoint}" +} + +release_asset_url() { + local release_json="$1" + local asset_name="$2" + local count + count=$(jq --arg name "$asset_name" '[.assets[] | select(.name == $name and .state == "uploaded")] | length' <<<"$release_json") + if [[ "$count" -ne 1 ]]; then + error "Expected exactly one '$asset_name' asset, found $count." + return 1 + fi + jq -r --arg name "$asset_name" '.assets[] | select(.name == $name and .state == "uploaded") | .browser_download_url' <<<"$release_json" +} + +sha256() { + local file="$1" + if command -v sha256sum &>/dev/null; then + sha256sum "$file" | awk '{print $1}' + elif command -v shasum &>/dev/null; then + shasum -a 256 "$file" | awk '{print $1}' + else + error "SHA-256 verification requires sha256sum or shasum." + return 1 + fi +} case "${RUNNER_OS:-}" in Linux) @@ -29,15 +77,56 @@ case "${RUNNER_OS:-}" in fi ;; *) - echo "::error::Unsupported OS: ${RUNNER_OS:-unknown}" + error "Unsupported OS: ${RUNNER_OS:-unknown}" exit 1 ;; esac +if [[ ! -f "$LEGACY_VERSIONS_PATH" ]]; then + error "Legacy release metadata not found at $LEGACY_VERSIONS_PATH." + exit 1 +fi + +RELEASE_JSON="" +RESOLVED_VERSION="$CLI_VERSION" if [[ "$CLI_VERSION" == "latest" ]]; then - URL="https://github.com/linear/linear-release/releases/latest/download/$ASSET" + if ! command -v jq &>/dev/null; then + error "jq is required to resolve and verify the latest CLI release." + exit 1 + fi + RELEASE_JSON=$(fetch_release "latest") + RESOLVED_VERSION=$(jq -er '.tag_name | select(type == "string" and length > 0)' <<<"$RELEASE_JSON") + echo "Resolved latest Linear Release CLI to $RESOLVED_VERSION" +fi + +VERIFY_RELEASE=false +if ! is_legacy_version "$RESOLVED_VERSION"; then + VERIFY_RELEASE=true + if ! command -v jq &>/dev/null; then + error "jq is required to verify CLI release $RESOLVED_VERSION." + exit 1 + fi + + if [[ -z "$RELEASE_JSON" ]]; then + ENCODED_VERSION=$(jq -rn --arg version "$RESOLVED_VERSION" '$version | @uri') + RELEASE_JSON=$(fetch_release "tags/${ENCODED_VERSION}") + fi + + RELEASE_TAG=$(jq -er '.tag_name | select(type == "string" and length > 0)' <<<"$RELEASE_JSON") + if [[ "$RELEASE_TAG" != "$RESOLVED_VERSION" ]]; then + error "Release metadata returned tag '$RELEASE_TAG', expected '$RESOLVED_VERSION'." + exit 1 + fi + if [[ "$(jq -r '.immutable' <<<"$RELEASE_JSON")" != "true" ]]; then + error "CLI release $RESOLVED_VERSION is not immutable. Refusing to execute its assets." + exit 1 + fi + + URL=$(release_asset_url "$RELEASE_JSON" "$ASSET") + CHECKSUMS_URL=$(release_asset_url "$RELEASE_JSON" "checksums.txt") else - URL="https://github.com/linear/linear-release/releases/download/$CLI_VERSION/$ASSET" + URL="https://github.com/linear/linear-release/releases/download/$RESOLVED_VERSION/$ASSET" + echo "::notice::CLI release $RESOLVED_VERSION predates artifact verification; continuing with the legacy installation path." fi echo "Downloading Linear Release CLI from $URL" @@ -48,7 +137,38 @@ if [[ -n "${GITHUB_TOKEN:-}" ]]; then curl_args+=(-H "Authorization: Bearer ${GITHUB_TOKEN}") fi -curl "${curl_args[@]}" "$URL" -o "$BIN_PATH" -chmod +x "$BIN_PATH" +TEMP_ROOT="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" +TEMP_DIR=$(mktemp -d "${TEMP_ROOT%/}/linear-release.XXXXXX") +trap 'rm -rf "$TEMP_DIR"' EXIT +DOWNLOADED_BIN="${TEMP_DIR}/${ASSET}" + +curl "${curl_args[@]}" "$URL" -o "$DOWNLOADED_BIN" + +if [[ "$VERIFY_RELEASE" == "true" ]]; then + CHECKSUMS_PATH="${TEMP_DIR}/checksums.txt" + curl "${curl_args[@]}" "$CHECKSUMS_URL" -o "$CHECKSUMS_PATH" + + MATCH_COUNT=$(awk -v asset="$ASSET" '$2 == asset {count++} END {print count + 0}' "$CHECKSUMS_PATH") + if [[ "$MATCH_COUNT" -ne 1 ]]; then + error "Expected exactly one checksum for '$ASSET', found $MATCH_COUNT." + exit 1 + fi + + EXPECTED_SHA256=$(awk -v asset="$ASSET" '$2 == asset {print $1}' "$CHECKSUMS_PATH") + if [[ ! "$EXPECTED_SHA256" =~ ^[[:xdigit:]]{64}$ ]]; then + error "Malformed SHA-256 checksum for '$ASSET'." + exit 1 + fi + EXPECTED_SHA256=$(tr '[:upper:]' '[:lower:]' <<<"$EXPECTED_SHA256") + ACTUAL_SHA256=$(sha256 "$DOWNLOADED_BIN") + if [[ "$ACTUAL_SHA256" != "$EXPECTED_SHA256" ]]; then + error "SHA-256 checksum mismatch for '$ASSET'." + exit 1 + fi + echo "Verified SHA-256 checksum for $ASSET from immutable release $RESOLVED_VERSION" +fi + +chmod +x "$DOWNLOADED_BIN" +mv -f "$DOWNLOADED_BIN" "$BIN_PATH" echo "Linear Release CLI installed at $BIN_PATH" diff --git a/legacy-versions.txt b/legacy-versions.txt new file mode 100644 index 0000000..c376aad --- /dev/null +++ b/legacy-versions.txt @@ -0,0 +1,29 @@ +# Releases published before immutable checksum verification was introduced. +# Do not add new releases to this compatibility list. +v0.1.0 +v0.2.0 +v0.3.0 +v0.4.0 +v0.5.0 +v0.6.0 +v0.6.1 +v0.6.2 +v0.6.3 +v0.6.4 +v0.7.0 +v0.7.1 +v0.8.0 +v0.9.0 +v0.10.0 +v0.11.0 +v0.11.1 +v0.11.2 +v0.12.0 +v0.13.0 +v0.14.0 +v0.14.1 +v0.14.2 +v0.14.3 +v0.14.4 +v0.15.0 +v0.16.0 diff --git a/tests/install.test.sh b/tests/install.test.sh new file mode 100644 index 0000000..7d62a0f --- /dev/null +++ b/tests/install.test.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEST_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/linear-release-install-tests.XXXXXX") +trap 'rm -rf "$TEST_ROOT"' EXIT + +MOCK_PATH="${REPO_ROOT}/tests/mocks" +REAL_CHMOD=$(command -v chmod) +chmod +x "$MOCK_PATH/curl" "$MOCK_PATH/chmod" "$MOCK_PATH/uname" + +LAST_STATUS=0 +CASE_DIR="" +OUTPUT_DIR="" +MOCK_RELEASE_JSON="" +MOCK_BINARY="" +MOCK_CHECKSUMS="" +MOCK_CURL_LOG="" +MOCK_CHMOD_LOG="" + +sha256() { + if command -v sha256sum &>/dev/null; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +new_case() { + CASE_DIR="${TEST_ROOT}/$1" + OUTPUT_DIR="${CASE_DIR}/action" + MOCK_RELEASE_JSON="${CASE_DIR}/release.json" + MOCK_BINARY="${CASE_DIR}/binary" + MOCK_CHECKSUMS="${CASE_DIR}/checksums.txt" + MOCK_CURL_LOG="${CASE_DIR}/curl.log" + MOCK_CHMOD_LOG="${CASE_DIR}/chmod.log" + mkdir -p "$OUTPUT_DIR" + : >"$MOCK_CURL_LOG" + : >"$MOCK_CHMOD_LOG" +} + +write_release() { + local tag="$1" + local immutable="$2" + local asset="$3" + local include_checksums="$4" + local checksum_asset="" + if [[ "$include_checksums" == "true" ]]; then + checksum_asset=',{"name":"checksums.txt","state":"uploaded","browser_download_url":"https://downloads.example/checksums.txt"}' + fi + printf '{"tag_name":"%s","immutable":%s,"assets":[{"name":"%s","state":"uploaded","browser_download_url":"https://downloads.example/%s"}%s]}\n' \ + "$tag" "$immutable" "$asset" "$asset" "$checksum_asset" >"$MOCK_RELEASE_JSON" +} + +invoke_installer() { + local version="$1" + local runner_os="$2" + local arch="$3" + set +e + env \ + PATH="${MOCK_PATH}:$PATH" \ + CLI_VERSION="$version" \ + GITHUB_ACTION_PATH="$OUTPUT_DIR" \ + RUNNER_OS="$runner_os" \ + RUNNER_TEMP="$CASE_DIR" \ + MOCK_ARCH="$arch" \ + MOCK_RELEASE_JSON="$MOCK_RELEASE_JSON" \ + MOCK_BINARY="$MOCK_BINARY" \ + MOCK_CHECKSUMS="$MOCK_CHECKSUMS" \ + MOCK_CURL_LOG="$MOCK_CURL_LOG" \ + MOCK_CHMOD_LOG="$MOCK_CHMOD_LOG" \ + REAL_CHMOD="$REAL_CHMOD" \ + bash "$REPO_ROOT/install.sh" >"${CASE_DIR}/output.log" 2>&1 + LAST_STATUS=$? + set -e +} + +assert_success() { + if [[ "$LAST_STATUS" -ne 0 ]]; then + command cat "${CASE_DIR}/output.log" >&2 + echo "Expected installer to succeed, got $LAST_STATUS" >&2 + return 1 + fi +} + +assert_failure() { + if [[ "$LAST_STATUS" -eq 0 ]]; then + echo "Expected installer to fail" >&2 + return 1 + fi +} + +assert_contains() { + local file="$1" + local expected="$2" + if ! grep -Fq -- "$expected" "$file"; then + echo "Expected $file to contain: $expected" >&2 + command cat "$file" >&2 + return 1 + fi +} + +assert_not_installed() { + if [[ -e "${OUTPUT_DIR}/linear-release" ]]; then + echo "Binary was installed after verification failed" >&2 + return 1 + fi + if [[ -s "$MOCK_CHMOD_LOG" ]]; then + echo "chmod ran before verification succeeded" >&2 + return 1 + fi +} + +test_legacy_release() { + new_case "legacy-release" + printf 'legacy-binary\n' >"$MOCK_BINARY" + invoke_installer "v0.16.0" "Linux" "x86_64" + assert_success + assert_contains "$MOCK_CURL_LOG" "/v0.16.0/linear-release-linux-x64" + cmp "$MOCK_BINARY" "${OUTPUT_DIR}/linear-release" +} + +test_legacy_latest() { + new_case "legacy-latest" + printf 'legacy-binary\n' >"$MOCK_BINARY" + : >"$MOCK_CHECKSUMS" + write_release "v0.16.0" false "linear-release-linux-x64" false + invoke_installer "latest" "Linux" "x86_64" + assert_success + assert_contains "$MOCK_CURL_LOG" "api.github.com" + assert_contains "$MOCK_CURL_LOG" "/v0.16.0/linear-release-linux-x64" +} + +test_verified_release() { + new_case "verified-release" + printf 'verified-binary\n' >"$MOCK_BINARY" + write_release "v0.17.0" true "linear-release-linux-x64" true + printf '%s linear-release-linux-x64\n' "$(sha256 "$MOCK_BINARY")" >"$MOCK_CHECKSUMS" + invoke_installer "v0.17.0" "Linux" "x86_64" + assert_success + assert_contains "${CASE_DIR}/output.log" "Verified SHA-256 checksum" + cmp "$MOCK_BINARY" "${OUTPUT_DIR}/linear-release" + [[ -x "${OUTPUT_DIR}/linear-release" ]] +} + +test_tampered_release() { + new_case "tampered-release" + printf 'tampered-binary\n' >"$MOCK_BINARY" + write_release "v0.17.0" true "linear-release-linux-x64" true + printf '%064d linear-release-linux-x64\n' 0 >"$MOCK_CHECKSUMS" + invoke_installer "v0.17.0" "Linux" "x86_64" + assert_failure + assert_contains "${CASE_DIR}/output.log" "checksum mismatch" + assert_not_installed +} + +test_invalid_metadata() { + new_case "missing-checksum" + printf 'verified-binary\n' >"$MOCK_BINARY" + : >"$MOCK_CHECKSUMS" + write_release "v0.17.0" true "linear-release-linux-x64" false + invoke_installer "v0.17.0" "Linux" "x86_64" + assert_failure + assert_contains "${CASE_DIR}/output.log" "checksums.txt" + assert_not_installed + + new_case "mutable-release" + printf 'verified-binary\n' >"$MOCK_BINARY" + : >"$MOCK_CHECKSUMS" + write_release "v0.17.0" false "linear-release-linux-x64" true + invoke_installer "v0.17.0" "Linux" "x86_64" + assert_failure + assert_contains "${CASE_DIR}/output.log" "is not immutable" + assert_not_installed +} + +test_unsupported_platform() { + new_case "unsupported-platform" + printf 'binary\n' >"$MOCK_BINARY" + : >"$MOCK_RELEASE_JSON" + : >"$MOCK_CHECKSUMS" + invoke_installer "v0.16.0" "Windows" "x86_64" + assert_failure + assert_contains "${CASE_DIR}/output.log" "Unsupported OS" + if [[ -s "$MOCK_CURL_LOG" ]]; then + echo "Network request occurred for an unsupported platform" >&2 + return 1 + fi +} + +tests=( + test_legacy_release + test_legacy_latest + test_verified_release + test_tampered_release + test_invalid_metadata + test_unsupported_platform +) + +for test_name in "${tests[@]}"; do + "$test_name" + echo "ok - $test_name" +done diff --git a/tests/mocks/chmod b/tests/mocks/chmod new file mode 100755 index 0000000..4477658 --- /dev/null +++ b/tests/mocks/chmod @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +printf '%s\n' "$*" >>"$MOCK_CHMOD_LOG" +"$REAL_CHMOD" "$@" diff --git a/tests/mocks/curl b/tests/mocks/curl new file mode 100755 index 0000000..e394c5a --- /dev/null +++ b/tests/mocks/curl @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +output="" +url="" +while [[ $# -gt 0 ]]; do + case "$1" in + -H) + shift 2 + ;; + -o) + output="$2" + shift 2 + ;; + -*) + shift + ;; + *) + url="$1" + shift + ;; + esac +done + +printf '%s\n' "$url" >>"$MOCK_CURL_LOG" +if [[ -n "${MOCK_CURL_FAIL_PATTERN:-}" && "$url" == *"$MOCK_CURL_FAIL_PATTERN"* ]]; then + exit 22 +fi + +if [[ "$url" == https://api.github.com/* ]]; then + source_file="$MOCK_RELEASE_JSON" +elif [[ "$url" == */checksums.txt ]]; then + source_file="$MOCK_CHECKSUMS" +elif [[ "$url" == */linear-release-* ]]; then + source_file="$MOCK_BINARY" +else + echo "Unexpected URL: $url" >&2 + exit 22 +fi + +if [[ -n "$output" ]]; then + cp "$source_file" "$output" +else + command cat "$source_file" +fi diff --git a/tests/mocks/uname b/tests/mocks/uname new file mode 100755 index 0000000..7fb0e53 --- /dev/null +++ b/tests/mocks/uname @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +printf '%s\n' "$MOCK_ARCH"