Skip to content

Commit 023b591

Browse files
committed
Verify future CLI release artifacts before execution
1 parent 0a25aba commit 023b591

9 files changed

Lines changed: 418 additions & 7 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ jobs:
1818
persist-credentials: false
1919

2020
- name: Lint shell scripts
21-
run: shellcheck install.sh run.sh
21+
run: shellcheck install.sh run.sh tests/install.test.sh tests/mocks/*
22+
23+
- name: Test installer verification
24+
run: bash tests/install.test.sh
2225

2326
- name: Validate action.yml
2427
run: |

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,8 @@ Set `dry_run: true` to preview what the action would do without touching Linear.
246246

247247
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.
248248

249+
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.
250+
249251
## Troubleshooting
250252

251253
**"Unsupported OS" or "Unsupported arch" error**

action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ inputs:
6262
required: false
6363
default: v0.16.0
6464
github_token:
65-
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.
65+
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.
6666
required: false
6767
default: ${{ github.token }}
6868

install.sh

Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,56 @@
22
set -euo pipefail
33

44
CLI_VERSION="${CLI_VERSION:-latest}"
5+
SCRIPT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
56
ACTION_PATH="${GITHUB_ACTION_PATH:-$(pwd)}"
67
BIN_PATH="${ACTION_PATH}/linear-release"
8+
LEGACY_VERSIONS_PATH="${SCRIPT_PATH}/legacy-versions.txt"
9+
RELEASES_API="https://api.github.com/repos/linear/linear-release/releases"
10+
11+
error() {
12+
echo "::error::$*" >&2
13+
}
14+
15+
is_legacy_version() {
16+
grep -Fqx -- "$1" "$LEGACY_VERSIONS_PATH"
17+
}
18+
19+
fetch_release() {
20+
local endpoint="$1"
21+
local api_curl_args=(
22+
-fsSL
23+
-H "Accept: application/vnd.github+json"
24+
-H "X-GitHub-Api-Version: 2026-03-10"
25+
)
26+
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
27+
api_curl_args+=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
28+
fi
29+
curl "${api_curl_args[@]}" "${RELEASES_API}/${endpoint}"
30+
}
31+
32+
release_asset_url() {
33+
local release_json="$1"
34+
local asset_name="$2"
35+
local count
36+
count=$(jq --arg name "$asset_name" '[.assets[] | select(.name == $name and .state == "uploaded")] | length' <<<"$release_json")
37+
if [[ "$count" -ne 1 ]]; then
38+
error "Expected exactly one '$asset_name' asset, found $count."
39+
return 1
40+
fi
41+
jq -r --arg name "$asset_name" '.assets[] | select(.name == $name and .state == "uploaded") | .browser_download_url' <<<"$release_json"
42+
}
43+
44+
sha256() {
45+
local file="$1"
46+
if command -v sha256sum &>/dev/null; then
47+
sha256sum "$file" | awk '{print $1}'
48+
elif command -v shasum &>/dev/null; then
49+
shasum -a 256 "$file" | awk '{print $1}'
50+
else
51+
error "SHA-256 verification requires sha256sum or shasum."
52+
return 1
53+
fi
54+
}
755

856
case "${RUNNER_OS:-}" in
957
Linux)
@@ -29,15 +77,56 @@ case "${RUNNER_OS:-}" in
2977
fi
3078
;;
3179
*)
32-
echo "::error::Unsupported OS: ${RUNNER_OS:-unknown}"
80+
error "Unsupported OS: ${RUNNER_OS:-unknown}"
3381
exit 1
3482
;;
3583
esac
3684

85+
if [[ ! -f "$LEGACY_VERSIONS_PATH" ]]; then
86+
error "Legacy release metadata not found at $LEGACY_VERSIONS_PATH."
87+
exit 1
88+
fi
89+
90+
RELEASE_JSON=""
91+
RESOLVED_VERSION="$CLI_VERSION"
3792
if [[ "$CLI_VERSION" == "latest" ]]; then
38-
URL="https://github.com/linear/linear-release/releases/latest/download/$ASSET"
93+
if ! command -v jq &>/dev/null; then
94+
error "jq is required to resolve and verify the latest CLI release."
95+
exit 1
96+
fi
97+
RELEASE_JSON=$(fetch_release "latest")
98+
RESOLVED_VERSION=$(jq -er '.tag_name | select(type == "string" and length > 0)' <<<"$RELEASE_JSON")
99+
echo "Resolved latest Linear Release CLI to $RESOLVED_VERSION"
100+
fi
101+
102+
VERIFY_RELEASE=false
103+
if ! is_legacy_version "$RESOLVED_VERSION"; then
104+
VERIFY_RELEASE=true
105+
if ! command -v jq &>/dev/null; then
106+
error "jq is required to verify CLI release $RESOLVED_VERSION."
107+
exit 1
108+
fi
109+
110+
if [[ -z "$RELEASE_JSON" ]]; then
111+
ENCODED_VERSION=$(jq -rn --arg version "$RESOLVED_VERSION" '$version | @uri')
112+
RELEASE_JSON=$(fetch_release "tags/${ENCODED_VERSION}")
113+
fi
114+
115+
RELEASE_TAG=$(jq -er '.tag_name | select(type == "string" and length > 0)' <<<"$RELEASE_JSON")
116+
if [[ "$RELEASE_TAG" != "$RESOLVED_VERSION" ]]; then
117+
error "Release metadata returned tag '$RELEASE_TAG', expected '$RESOLVED_VERSION'."
118+
exit 1
119+
fi
120+
if [[ "$(jq -r '.immutable' <<<"$RELEASE_JSON")" != "true" ]]; then
121+
error "CLI release $RESOLVED_VERSION is not immutable. Refusing to execute its assets."
122+
exit 1
123+
fi
124+
125+
URL=$(release_asset_url "$RELEASE_JSON" "$ASSET")
126+
CHECKSUMS_URL=$(release_asset_url "$RELEASE_JSON" "checksums.txt")
39127
else
40-
URL="https://github.com/linear/linear-release/releases/download/$CLI_VERSION/$ASSET"
128+
URL="https://github.com/linear/linear-release/releases/download/$RESOLVED_VERSION/$ASSET"
129+
echo "::notice::CLI release $RESOLVED_VERSION predates artifact verification; continuing with the legacy installation path."
41130
fi
42131

43132
echo "Downloading Linear Release CLI from $URL"
@@ -48,7 +137,38 @@ if [[ -n "${GITHUB_TOKEN:-}" ]]; then
48137
curl_args+=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
49138
fi
50139

51-
curl "${curl_args[@]}" "$URL" -o "$BIN_PATH"
52-
chmod +x "$BIN_PATH"
140+
TEMP_ROOT="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
141+
TEMP_DIR=$(mktemp -d "${TEMP_ROOT%/}/linear-release.XXXXXX")
142+
trap 'rm -rf "$TEMP_DIR"' EXIT
143+
DOWNLOADED_BIN="${TEMP_DIR}/${ASSET}"
144+
145+
curl "${curl_args[@]}" "$URL" -o "$DOWNLOADED_BIN"
146+
147+
if [[ "$VERIFY_RELEASE" == "true" ]]; then
148+
CHECKSUMS_PATH="${TEMP_DIR}/checksums.txt"
149+
curl "${curl_args[@]}" "$CHECKSUMS_URL" -o "$CHECKSUMS_PATH"
150+
151+
MATCH_COUNT=$(awk -v asset="$ASSET" '$2 == asset {count++} END {print count + 0}' "$CHECKSUMS_PATH")
152+
if [[ "$MATCH_COUNT" -ne 1 ]]; then
153+
error "Expected exactly one checksum for '$ASSET', found $MATCH_COUNT."
154+
exit 1
155+
fi
156+
157+
EXPECTED_SHA256=$(awk -v asset="$ASSET" '$2 == asset {print $1}' "$CHECKSUMS_PATH")
158+
if [[ ! "$EXPECTED_SHA256" =~ ^[[:xdigit:]]{64}$ ]]; then
159+
error "Malformed SHA-256 checksum for '$ASSET'."
160+
exit 1
161+
fi
162+
EXPECTED_SHA256=$(tr '[:upper:]' '[:lower:]' <<<"$EXPECTED_SHA256")
163+
ACTUAL_SHA256=$(sha256 "$DOWNLOADED_BIN")
164+
if [[ "$ACTUAL_SHA256" != "$EXPECTED_SHA256" ]]; then
165+
error "SHA-256 checksum mismatch for '$ASSET'."
166+
exit 1
167+
fi
168+
echo "Verified SHA-256 checksum for $ASSET from immutable release $RESOLVED_VERSION"
169+
fi
170+
171+
chmod +x "$DOWNLOADED_BIN"
172+
mv -f "$DOWNLOADED_BIN" "$BIN_PATH"
53173

54174
echo "Linear Release CLI installed at $BIN_PATH"

legacy-versions.txt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Releases published before immutable checksum verification was introduced.
2+
# Do not add new releases to this compatibility list.
3+
v0.1.0
4+
v0.2.0
5+
v0.3.0
6+
v0.4.0
7+
v0.5.0
8+
v0.6.0
9+
v0.6.1
10+
v0.6.2
11+
v0.6.3
12+
v0.6.4
13+
v0.7.0
14+
v0.7.1
15+
v0.8.0
16+
v0.9.0
17+
v0.10.0
18+
v0.11.0
19+
v0.11.1
20+
v0.11.2
21+
v0.12.0
22+
v0.13.0
23+
v0.14.0
24+
v0.14.1
25+
v0.14.2
26+
v0.14.3
27+
v0.14.4
28+
v0.15.0
29+
v0.16.0

0 commit comments

Comments
 (0)