Skip to content

Commit 8db66b6

Browse files
authored
Verify CLI installs against committed checksums (#73)
* Skip the automatic GitHub token on GitHub Enterprise runners * Point to github_token when the release metadata fetch fails * Document a github.com token for rate-limited Enterprise installs * Verify installs against committed CLI checksums * Pin CLI checksums in release script and backfill v0.17.0 to v0.18.0 * Verify pinned CLI checksums against releases in CI
1 parent 65ed0ec commit 8db66b6

7 files changed

Lines changed: 163 additions & 11 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
persist-credentials: false
1919

2020
- name: Lint shell scripts
21-
run: shellcheck install.sh run.sh tests/*.test.sh tests/mocks/*
21+
run: shellcheck install.sh run.sh scripts/*.sh tests/*.test.sh tests/mocks/*
2222

2323
- name: Test installer verification
2424
run: bash tests/install.test.sh
@@ -31,6 +31,20 @@ jobs:
3131
npm install -g @action-validator/cli
3232
action-validator action.yml
3333
34+
verify-checksums:
35+
runs-on: ubuntu-latest
36+
permissions:
37+
contents: read
38+
steps:
39+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
40+
with:
41+
persist-credentials: false
42+
43+
- name: Verify pinned CLI checksums against releases
44+
run: bash scripts/verify-checksums.sh
45+
env:
46+
GH_TOKEN: ${{ github.token }}
47+
3448
test-install:
3549
strategy:
3650
matrix:
@@ -54,7 +68,7 @@ jobs:
5468

5569
sync-release:
5670
name: Sync release management
57-
needs: [lint, test-install]
71+
needs: [lint, verify-checksums, test-install]
5872
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
5973
permissions:
6074
contents: read

‎RELEASING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ The script runs preflight checks and then:
4646
3. Verifies the working tree is clean, you're on `main`, and it's up to date with `origin/main`
4747
4. Ensures the `v<version>` tag and `release/<version>` branch don't already exist
4848
5. Creates a `release/<version>` branch
49-
6. Bumps the version in [`VERSION`](./VERSION), the `cli_version` default in [`action.yml`](./action.yml), and the inputs table in [`README.md`](./README.md)
49+
6. Pins the CLI release's SHA-256 checksums in [`cli-checksums.txt`](./cli-checksums.txt) (the release must be immutable), then bumps the version in [`VERSION`](./VERSION), the `cli_version` default in [`action.yml`](./action.yml), and the inputs table in [`README.md`](./README.md)
5050
7. Commits the change and pushes the branch
5151
8. Opens a PR against `main` via `gh pr create`
5252

‎cli-checksums.txt‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
v0.17.0 163a64363b9033a250c04f1002a974668c48e7216a49c4f6939bf3887957c290 linear-release-linux-x64
2+
v0.17.0 ac08beea92668a5730970bf083f2079e212db00965e1c28050991d131c000475 linear-release-linux-arm64
3+
v0.17.0 0dc1f5e0337518b6b49ef5ec1ce69ef6616cc393d70be8d994f85362588587db linear-release-darwin-arm64
4+
v0.17.0 7c53c67422e9a513e3eddc770dd5d91609b517593ae7b3b7da4820b64dc22a57 linear-release-darwin-x64
5+
v0.17.1 122461a09eadb74e5be1a57d7127fce7ed3b71a7a3b57abf424710c3e862cd58 linear-release-linux-x64
6+
v0.17.1 a66b02cb6a0c5e8065f198b4b53299538c3d108b98d3ecc28d1347d241c8fd6f linear-release-linux-arm64
7+
v0.17.1 bf7c011b3749ed81493a9468ed2d8fe3a28845fa01d511c083b100da9a0198d5 linear-release-darwin-arm64
8+
v0.17.1 65c353fb14bf0a82680424ffff7ce9855202ab06118ffae4de6d497c1660dfea linear-release-darwin-x64
9+
v0.17.2 4b33790f8e874e798404d9cb09e30569c8a80202095f763a4ec4dd8bbe1ba579 linear-release-linux-x64
10+
v0.17.2 4aca57fdb5a2055144c9ae6fb4bdab2b3fea99d54fd18fa457bc3a407921e6fa linear-release-linux-arm64
11+
v0.17.2 d94a06c124a449d22a7b10d98220ba74539cc642659f49ac5c0ee2712c0d68df linear-release-darwin-arm64
12+
v0.17.2 886962b47cf9d926826d65c0891b1919b8b23c1c882bde65cffa646ba0023da8 linear-release-darwin-x64
13+
v0.18.0 0476bc8b8c23c1ed3150a55fdeb17c91d6cafcdf5f52dab74b4f89539b3c374a linear-release-linux-x64
14+
v0.18.0 de74648749a540f1ec4f85d057dc3f62a5c442d560b99ce2eb91b7a1b13b1087 linear-release-linux-arm64
15+
v0.18.0 91060da15543d38b417f496e9a17f9991799e1e0c783126612c37e802766d711 linear-release-darwin-arm64
16+
v0.18.0 e00384ead55982e1ded0e84ee1d8bd0ab3030d7b3647d9569e46203a9e83a49e linear-release-darwin-x64

‎install.sh‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ SCRIPT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
66
ACTION_PATH="${GITHUB_ACTION_PATH:-$(pwd)}"
77
BIN_PATH="${ACTION_PATH}/linear-release"
88
LEGACY_VERSIONS_PATH="${SCRIPT_PATH}/legacy-versions.txt"
9+
CHECKSUMS_FILE_PATH="${SCRIPT_PATH}/cli-checksums.txt"
910
RELEASES_API="https://api.github.com/repos/linear/linear-release/releases"
1011

1112
error() {
@@ -90,6 +91,11 @@ if [[ ! -f "$LEGACY_VERSIONS_PATH" ]]; then
9091
exit 1
9192
fi
9293

94+
if [[ ! -f "$CHECKSUMS_FILE_PATH" ]]; then
95+
error "CLI checksum metadata not found at $CHECKSUMS_FILE_PATH."
96+
exit 1
97+
fi
98+
9399
RELEASE_JSON=""
94100
RESOLVED_VERSION="$CLI_VERSION"
95101
if [[ "$CLI_VERSION" == "latest" ]]; then
@@ -102,9 +108,24 @@ if [[ "$CLI_VERSION" == "latest" ]]; then
102108
echo "Resolved latest Linear Release CLI to $RESOLVED_VERSION"
103109
fi
104110

105-
VERIFY_RELEASE=false
106-
if ! is_legacy_version "$RESOLVED_VERSION"; then
107-
VERIFY_RELEASE=true
111+
EXPECTED_SHA256=""
112+
CHECKSUMS_URL=""
113+
VERIFY_SOURCE=""
114+
COMMITTED_COUNT=$(awk -v tag="$RESOLVED_VERSION" -v asset="$ASSET" '$1 == tag && $3 == asset {count++} END {print count + 0}' "$CHECKSUMS_FILE_PATH")
115+
if [[ "$COMMITTED_COUNT" -gt 1 ]]; then
116+
error "Expected at most one committed checksum for '$ASSET' in $RESOLVED_VERSION, found $COMMITTED_COUNT."
117+
exit 1
118+
fi
119+
120+
if [[ "$COMMITTED_COUNT" -eq 1 ]]; then
121+
EXPECTED_SHA256=$(awk -v tag="$RESOLVED_VERSION" -v asset="$ASSET" '$1 == tag && $3 == asset {print $2}' "$CHECKSUMS_FILE_PATH")
122+
if [[ ! "$EXPECTED_SHA256" =~ ^[[:xdigit:]]{64}$ ]]; then
123+
error "Malformed committed SHA-256 checksum for '$ASSET'."
124+
exit 1
125+
fi
126+
VERIFY_SOURCE="committed checksums for $RESOLVED_VERSION"
127+
URL="https://github.com/linear/linear-release/releases/download/$RESOLVED_VERSION/$ASSET"
128+
elif ! is_legacy_version "$RESOLVED_VERSION"; then
108129
if ! command -v jq &>/dev/null; then
109130
error "jq is required to verify CLI release $RESOLVED_VERSION."
110131
exit 1
@@ -127,6 +148,7 @@ if ! is_legacy_version "$RESOLVED_VERSION"; then
127148

128149
URL=$(release_asset_url "$RELEASE_JSON" "$ASSET")
129150
CHECKSUMS_URL=$(release_asset_url "$RELEASE_JSON" "checksums.txt")
151+
VERIFY_SOURCE="immutable release $RESOLVED_VERSION"
130152
else
131153
URL="https://github.com/linear/linear-release/releases/download/$RESOLVED_VERSION/$ASSET"
132154
echo "::notice::CLI release $RESOLVED_VERSION predates artifact verification; continuing with the legacy installation path."
@@ -147,7 +169,7 @@ DOWNLOADED_BIN="${TEMP_DIR}/${ASSET}"
147169

148170
curl "${curl_args[@]}" "$URL" -o "$DOWNLOADED_BIN"
149171

150-
if [[ "$VERIFY_RELEASE" == "true" ]]; then
172+
if [[ -n "$CHECKSUMS_URL" ]]; then
151173
CHECKSUMS_PATH="${TEMP_DIR}/checksums.txt"
152174
curl "${curl_args[@]}" "$CHECKSUMS_URL" -o "$CHECKSUMS_PATH"
153175

@@ -162,13 +184,16 @@ if [[ "$VERIFY_RELEASE" == "true" ]]; then
162184
error "Malformed SHA-256 checksum for '$ASSET'."
163185
exit 1
164186
fi
187+
fi
188+
189+
if [[ -n "$EXPECTED_SHA256" ]]; then
165190
EXPECTED_SHA256=$(tr '[:upper:]' '[:lower:]' <<<"$EXPECTED_SHA256")
166191
ACTUAL_SHA256=$(sha256 "$DOWNLOADED_BIN")
167192
if [[ "$ACTUAL_SHA256" != "$EXPECTED_SHA256" ]]; then
168193
error "SHA-256 checksum mismatch for '$ASSET'."
169194
exit 1
170195
fi
171-
echo "Verified SHA-256 checksum for $ASSET from immutable release $RESOLVED_VERSION"
196+
echo "Verified SHA-256 checksum for $ASSET from $VERIFY_SOURCE"
172197
fi
173198

174199
chmod +x "$DOWNLOADED_BIN"

‎scripts/release.sh‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,41 @@ if git ls-remote --exit-code --heads origin "$BRANCH" &>/dev/null; then
116116
exit 1
117117
fi
118118

119+
# --- Pin CLI checksums ---
120+
CHECKSUMS_FILE="cli-checksums.txt"
121+
CLI_ASSETS=(linear-release-linux-x64 linear-release-linux-arm64 linear-release-darwin-arm64 linear-release-darwin-x64)
122+
123+
pin_cli_checksums() {
124+
local tag="v$CLI_VERSION"
125+
if grep -q "^$tag " "$CHECKSUMS_FILE"; then
126+
echo "Checksums for CLI $tag already pinned."
127+
return
128+
fi
129+
130+
echo "Pinning checksums for CLI $tag..."
131+
local immutable
132+
immutable=$(gh api "repos/linear/linear-release/releases/tags/$tag" --jq '.immutable')
133+
if [ "$immutable" != "true" ]; then
134+
echo "Error: CLI release $tag is not immutable."
135+
exit 1
136+
fi
137+
138+
local checksums asset matches hash lines=""
139+
checksums=$(gh release download -R linear/linear-release "$tag" -p checksums.txt -O -)
140+
for asset in "${CLI_ASSETS[@]}"; do
141+
matches=$(awk -v asset="$asset" '$2 == asset {print $1}' <<<"$checksums")
142+
hash=$(tr '[:upper:]' '[:lower:]' <<<"$matches")
143+
if [ "$(grep -c . <<<"$hash")" -ne 1 ] || ! echo "$hash" | grep -qE '^[0-9a-f]{64}$'; then
144+
echo "Error: Expected exactly one SHA-256 entry for '$asset' in $tag checksums.txt."
145+
exit 1
146+
fi
147+
lines+="$tag $hash $asset"$'\n'
148+
done
149+
printf '%s' "$lines" >> "$CHECKSUMS_FILE"
150+
}
151+
152+
pin_cli_checksums
153+
119154
# --- Create release branch and bump versions ---
120155
echo "Creating branch '$BRANCH'..."
121156
git checkout -b "$BRANCH"
@@ -134,7 +169,7 @@ echo "Updating cli_version reference in README.md to v$CLI_VERSION..."
134169
sed -i.bak -E "s/(\`v)[0-9]+\.[0-9]+\.[0-9]+(\` \| Linear Release CLI)/\1$CLI_VERSION\2/" README.md
135170
rm README.md.bak
136171

137-
git add VERSION action.yml README.md
172+
git add VERSION action.yml README.md "$CHECKSUMS_FILE"
138173
git commit -m "Release v$VERSION"
139174

140175
# --- Push and create PR ---

‎scripts/verify-checksums.sh‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
CHECKSUMS_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/cli-checksums.txt"
5+
status=0
6+
7+
while read -r tag; do
8+
if [[ "$(gh api "repos/linear/linear-release/releases/tags/$tag" --jq '.immutable' </dev/null)" != "true" ]]; then
9+
echo "::error::CLI release $tag is not immutable."
10+
status=1
11+
continue
12+
fi
13+
14+
published=$(gh release download -R linear/linear-release "$tag" -p checksums.txt -O - </dev/null)
15+
tag_status=0
16+
while read -r _ hash asset; do
17+
expected=$(awk -v asset="$asset" '$2 == asset {print tolower($1)}' <<<"$published")
18+
if [[ "$hash" != "$expected" ]]; then
19+
echo "::error::Pinned checksum for $asset in $tag does not match the release's checksums.txt."
20+
tag_status=1
21+
fi
22+
done < <(awk -v tag="$tag" '$1 == tag' "$CHECKSUMS_FILE")
23+
if [[ "$tag_status" -eq 0 ]]; then
24+
echo "Verified pinned checksums for $tag"
25+
else
26+
status=1
27+
fi
28+
done < <(awk '{print $1}' "$CHECKSUMS_FILE" | sort -u)
29+
30+
exit "$status"

‎tests/install.test.sh‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ chmod +x "$MOCK_PATH/curl" "$MOCK_PATH/chmod" "$MOCK_PATH/uname"
1111

1212
LAST_STATUS=0
1313
CASE_DIR=""
14+
INSTALL_DIR=""
1415
OUTPUT_DIR=""
1516
MOCK_RELEASE_JSON=""
1617
MOCK_BINARY=""
@@ -34,7 +35,10 @@ new_case() {
3435
MOCK_CHECKSUMS="${CASE_DIR}/checksums.txt"
3536
MOCK_CURL_LOG="${CASE_DIR}/curl.log"
3637
MOCK_CHMOD_LOG="${CASE_DIR}/chmod.log"
37-
mkdir -p "$OUTPUT_DIR"
38+
INSTALL_DIR="${CASE_DIR}/install"
39+
mkdir -p "$OUTPUT_DIR" "$INSTALL_DIR"
40+
cp "$REPO_ROOT/install.sh" "$REPO_ROOT/legacy-versions.txt" "$INSTALL_DIR/"
41+
: >"${INSTALL_DIR}/cli-checksums.txt"
3842
: >"$MOCK_CURL_LOG"
3943
: >"$MOCK_CHMOD_LOG"
4044
}
@@ -70,7 +74,7 @@ invoke_installer() {
7074
MOCK_CURL_LOG="$MOCK_CURL_LOG" \
7175
MOCK_CHMOD_LOG="$MOCK_CHMOD_LOG" \
7276
REAL_CHMOD="$REAL_CHMOD" \
73-
bash "$REPO_ROOT/install.sh" >"${CASE_DIR}/output.log" 2>&1
77+
bash "$INSTALL_DIR/install.sh" >"${CASE_DIR}/output.log" 2>&1
7478
LAST_STATUS=$?
7579
set -e
7680
}
@@ -154,6 +158,32 @@ test_tampered_release() {
154158
assert_not_installed
155159
}
156160

161+
test_committed_checksum() {
162+
new_case "committed-checksum"
163+
printf 'committed-binary\n' >"$MOCK_BINARY"
164+
printf 'v0.18.0 %s linear-release-linux-x64\n' "$(sha256 "$MOCK_BINARY")" >"${INSTALL_DIR}/cli-checksums.txt"
165+
invoke_installer "v0.18.0" "Linux" "x86_64"
166+
assert_success
167+
assert_contains "${CASE_DIR}/output.log" "Verified SHA-256 checksum"
168+
assert_contains "$MOCK_CURL_LOG" "/v0.18.0/linear-release-linux-x64"
169+
if grep -Eq 'api.github.com|checksums.txt' "$MOCK_CURL_LOG"; then
170+
echo "Committed checksum install made a metadata or checksums.txt request" >&2
171+
command cat "$MOCK_CURL_LOG" >&2
172+
return 1
173+
fi
174+
cmp "$MOCK_BINARY" "${OUTPUT_DIR}/linear-release"
175+
}
176+
177+
test_committed_checksum_mismatch() {
178+
new_case "committed-checksum-mismatch"
179+
printf 'committed-binary\n' >"$MOCK_BINARY"
180+
printf 'v0.18.0 %064d linear-release-linux-x64\n' 0 >"${INSTALL_DIR}/cli-checksums.txt"
181+
invoke_installer "v0.18.0" "Linux" "x86_64"
182+
assert_failure
183+
assert_contains "${CASE_DIR}/output.log" "checksum mismatch"
184+
assert_not_installed
185+
}
186+
157187
test_invalid_metadata() {
158188
new_case "missing-checksum"
159189
printf 'verified-binary\n' >"$MOCK_BINARY"
@@ -204,6 +234,8 @@ tests=(
204234
test_legacy_latest
205235
test_verified_release
206236
test_tampered_release
237+
test_committed_checksum
238+
test_committed_checksum_mismatch
207239
test_invalid_metadata
208240
test_metadata_fetch_failure
209241
test_unsupported_platform

0 commit comments

Comments
 (0)