Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down
130 changes: 125 additions & 5 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"
Expand All @@ -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"
29 changes: 29 additions & 0 deletions legacy-versions.txt
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading