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
18 changes: 16 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
persist-credentials: false

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

- name: Test installer verification
run: bash tests/install.test.sh
Expand All @@ -31,6 +31,20 @@ jobs:
npm install -g @action-validator/cli
action-validator action.yml

verify-checksums:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false

- name: Verify pinned CLI checksums against releases
run: bash scripts/verify-checksums.sh
env:
GH_TOKEN: ${{ github.token }}

test-install:
strategy:
matrix:
Expand All @@ -54,7 +68,7 @@ jobs:

sync-release:
name: Sync release management
needs: [lint, test-install]
needs: [lint, verify-checksums, test-install]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Once installed, run it from your AI agent with `/linear-release-setup` (or just
| `log_level` | No | | Log verbosity: `quiet` or `verbose`. Omit for default output. |
| `timeout` | No | `60` | Maximum time in seconds to wait for the command to complete |
| `cli_version` | No | `v0.18.0` | Linear Release CLI version to install |
| `github_token` | No | `${{ github.token }}` | GitHub token used to authenticate the CLI download. Authenticating avoids the low anonymous rate limit that can fail on busy or shared runners. Defaults to the workflow's automatic token; pass your own token to use a higher rate limit. |
| `github_token` | No | `${{ github.token }}` on github.com | github.com token used to authenticate the CLI download. Authenticating avoids the low anonymous rate limit that can fail on busy or shared runners. Defaults to the workflow's automatic token; pass your own token to use a higher rate limit. On GitHub Enterprise the automatic token isn't valid for github.com, so the download is anonymous unless you pass a github.com token. If anonymous requests hit the rate limit, pass a fine-grained personal access token with no permissions. |

## Outputs

Expand Down
2 changes: 1 addition & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ The script runs preflight checks and then:
3. Verifies the working tree is clean, you're on `main`, and it's up to date with `origin/main`
4. Ensures the `v<version>` tag and `release/<version>` branch don't already exist
5. Creates a `release/<version>` branch
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)
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)
7. Commits the change and pushes the branch
8. Opens a PR against `main` via `gh pr create`

Expand Down
4 changes: 2 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ inputs:
required: false
default: v0.18.0
github_token:
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.
description: github.com token used to authenticate release metadata and downloads. Defaults to the workflow's automatic token on github.com, which avoids the lower anonymous API rate limit. On GitHub Enterprise the automatic token is not valid for github.com, so requests are anonymous unless you pass a github.com token.
required: false
default: ${{ github.token }}
default: ${{ github.server_url == 'https://github.com' && github.token || '' }}

outputs:
release-id:
Expand Down
16 changes: 16 additions & 0 deletions cli-checksums.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
v0.17.0 163a64363b9033a250c04f1002a974668c48e7216a49c4f6939bf3887957c290 linear-release-linux-x64
v0.17.0 ac08beea92668a5730970bf083f2079e212db00965e1c28050991d131c000475 linear-release-linux-arm64
v0.17.0 0dc1f5e0337518b6b49ef5ec1ce69ef6616cc393d70be8d994f85362588587db linear-release-darwin-arm64
v0.17.0 7c53c67422e9a513e3eddc770dd5d91609b517593ae7b3b7da4820b64dc22a57 linear-release-darwin-x64
v0.17.1 122461a09eadb74e5be1a57d7127fce7ed3b71a7a3b57abf424710c3e862cd58 linear-release-linux-x64
v0.17.1 a66b02cb6a0c5e8065f198b4b53299538c3d108b98d3ecc28d1347d241c8fd6f linear-release-linux-arm64
v0.17.1 bf7c011b3749ed81493a9468ed2d8fe3a28845fa01d511c083b100da9a0198d5 linear-release-darwin-arm64
v0.17.1 65c353fb14bf0a82680424ffff7ce9855202ab06118ffae4de6d497c1660dfea linear-release-darwin-x64
v0.17.2 4b33790f8e874e798404d9cb09e30569c8a80202095f763a4ec4dd8bbe1ba579 linear-release-linux-x64
v0.17.2 4aca57fdb5a2055144c9ae6fb4bdab2b3fea99d54fd18fa457bc3a407921e6fa linear-release-linux-arm64
v0.17.2 d94a06c124a449d22a7b10d98220ba74539cc642659f49ac5c0ee2712c0d68df linear-release-darwin-arm64
v0.17.2 886962b47cf9d926826d65c0891b1919b8b23c1c882bde65cffa646ba0023da8 linear-release-darwin-x64
v0.18.0 0476bc8b8c23c1ed3150a55fdeb17c91d6cafcdf5f52dab74b4f89539b3c374a linear-release-linux-x64
v0.18.0 de74648749a540f1ec4f85d057dc3f62a5c442d560b99ce2eb91b7a1b13b1087 linear-release-linux-arm64
v0.18.0 91060da15543d38b417f496e9a17f9991799e1e0c783126612c37e802766d711 linear-release-darwin-arm64
v0.18.0 e00384ead55982e1ded0e84ee1d8bd0ab3030d7b3647d9569e46203a9e83a49e linear-release-darwin-x64
40 changes: 34 additions & 6 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ 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"
CHECKSUMS_FILE_PATH="${SCRIPT_PATH}/cli-checksums.txt"
RELEASES_API="https://api.github.com/repos/linear/linear-release/releases"

error() {
Expand All @@ -26,7 +27,10 @@ fetch_release() {
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
api_curl_args+=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
fi
curl "${api_curl_args[@]}" "${RELEASES_API}/${endpoint}"
if ! curl "${api_curl_args[@]}" "${RELEASES_API}/${endpoint}"; then
error "Could not fetch CLI release metadata from api.github.com. A 401 means github_token isn't valid for github.com; a 403 usually means the anonymous rate limit. Set github_token to a github.com token, such as a fine-grained personal access token with no permissions."
return 1
fi
}

release_asset_url() {
Expand Down Expand Up @@ -87,6 +91,11 @@ if [[ ! -f "$LEGACY_VERSIONS_PATH" ]]; then
exit 1
fi

if [[ ! -f "$CHECKSUMS_FILE_PATH" ]]; then
error "CLI checksum metadata not found at $CHECKSUMS_FILE_PATH."
exit 1
fi

RELEASE_JSON=""
RESOLVED_VERSION="$CLI_VERSION"
if [[ "$CLI_VERSION" == "latest" ]]; then
Expand All @@ -99,9 +108,24 @@ if [[ "$CLI_VERSION" == "latest" ]]; then
echo "Resolved latest Linear Release CLI to $RESOLVED_VERSION"
fi

VERIFY_RELEASE=false
if ! is_legacy_version "$RESOLVED_VERSION"; then
VERIFY_RELEASE=true
EXPECTED_SHA256=""
CHECKSUMS_URL=""
VERIFY_SOURCE=""
COMMITTED_COUNT=$(awk -v tag="$RESOLVED_VERSION" -v asset="$ASSET" '$1 == tag && $3 == asset {count++} END {print count + 0}' "$CHECKSUMS_FILE_PATH")
if [[ "$COMMITTED_COUNT" -gt 1 ]]; then
error "Expected at most one committed checksum for '$ASSET' in $RESOLVED_VERSION, found $COMMITTED_COUNT."
exit 1
fi

if [[ "$COMMITTED_COUNT" -eq 1 ]]; then
EXPECTED_SHA256=$(awk -v tag="$RESOLVED_VERSION" -v asset="$ASSET" '$1 == tag && $3 == asset {print $2}' "$CHECKSUMS_FILE_PATH")
if [[ ! "$EXPECTED_SHA256" =~ ^[[:xdigit:]]{64}$ ]]; then
error "Malformed committed SHA-256 checksum for '$ASSET'."
exit 1
fi
VERIFY_SOURCE="committed checksums for $RESOLVED_VERSION"
URL="https://github.com/linear/linear-release/releases/download/$RESOLVED_VERSION/$ASSET"
elif ! is_legacy_version "$RESOLVED_VERSION"; then
if ! command -v jq &>/dev/null; then
error "jq is required to verify CLI release $RESOLVED_VERSION."
exit 1
Expand All @@ -124,6 +148,7 @@ if ! is_legacy_version "$RESOLVED_VERSION"; then

URL=$(release_asset_url "$RELEASE_JSON" "$ASSET")
CHECKSUMS_URL=$(release_asset_url "$RELEASE_JSON" "checksums.txt")
VERIFY_SOURCE="immutable release $RESOLVED_VERSION"
else
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."
Expand All @@ -144,7 +169,7 @@ DOWNLOADED_BIN="${TEMP_DIR}/${ASSET}"

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

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

Expand All @@ -159,13 +184,16 @@ if [[ "$VERIFY_RELEASE" == "true" ]]; then
error "Malformed SHA-256 checksum for '$ASSET'."
exit 1
fi
fi

if [[ -n "$EXPECTED_SHA256" ]]; then
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"
echo "Verified SHA-256 checksum for $ASSET from $VERIFY_SOURCE"
fi

chmod +x "$DOWNLOADED_BIN"
Expand Down
37 changes: 36 additions & 1 deletion scripts/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,41 @@ if git ls-remote --exit-code --heads origin "$BRANCH" &>/dev/null; then
exit 1
fi

# --- Pin CLI checksums ---
CHECKSUMS_FILE="cli-checksums.txt"
CLI_ASSETS=(linear-release-linux-x64 linear-release-linux-arm64 linear-release-darwin-arm64 linear-release-darwin-x64)

pin_cli_checksums() {
local tag="v$CLI_VERSION"
if grep -q "^$tag " "$CHECKSUMS_FILE"; then
echo "Checksums for CLI $tag already pinned."
return
fi

echo "Pinning checksums for CLI $tag..."
local immutable
immutable=$(gh api "repos/linear/linear-release/releases/tags/$tag" --jq '.immutable')
if [ "$immutable" != "true" ]; then
echo "Error: CLI release $tag is not immutable."
exit 1
fi

local checksums asset matches hash lines=""
checksums=$(gh release download -R linear/linear-release "$tag" -p checksums.txt -O -)
for asset in "${CLI_ASSETS[@]}"; do
matches=$(awk -v asset="$asset" '$2 == asset {print $1}' <<<"$checksums")
hash=$(tr '[:upper:]' '[:lower:]' <<<"$matches")
if [ "$(grep -c . <<<"$hash")" -ne 1 ] || ! echo "$hash" | grep -qE '^[0-9a-f]{64}$'; then
echo "Error: Expected exactly one SHA-256 entry for '$asset' in $tag checksums.txt."
exit 1
fi
lines+="$tag $hash $asset"$'\n'
done
printf '%s' "$lines" >> "$CHECKSUMS_FILE"
}

pin_cli_checksums

# --- Create release branch and bump versions ---
echo "Creating branch '$BRANCH'..."
git checkout -b "$BRANCH"
Expand All @@ -134,7 +169,7 @@ echo "Updating cli_version reference in README.md to v$CLI_VERSION..."
sed -i.bak -E "s/(\`v)[0-9]+\.[0-9]+\.[0-9]+(\` \| Linear Release CLI)/\1$CLI_VERSION\2/" README.md
rm README.md.bak

git add VERSION action.yml README.md
git add VERSION action.yml README.md "$CHECKSUMS_FILE"
git commit -m "Release v$VERSION"

# --- Push and create PR ---
Expand Down
30 changes: 30 additions & 0 deletions scripts/verify-checksums.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail

CHECKSUMS_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/cli-checksums.txt"
status=0

while read -r tag; do
if [[ "$(gh api "repos/linear/linear-release/releases/tags/$tag" --jq '.immutable' </dev/null)" != "true" ]]; then
echo "::error::CLI release $tag is not immutable."
status=1
continue
fi

published=$(gh release download -R linear/linear-release "$tag" -p checksums.txt -O - </dev/null)
tag_status=0
while read -r _ hash asset; do
expected=$(awk -v asset="$asset" '$2 == asset {print tolower($1)}' <<<"$published")
if [[ "$hash" != "$expected" ]]; then
echo "::error::Pinned checksum for $asset in $tag does not match the release's checksums.txt."
tag_status=1
fi
done < <(awk -v tag="$tag" '$1 == tag' "$CHECKSUMS_FILE")
if [[ "$tag_status" -eq 0 ]]; then
echo "Verified pinned checksums for $tag"
else
status=1
fi
done < <(awk '{print $1}' "$CHECKSUMS_FILE" | sort -u)

exit "$status"
48 changes: 46 additions & 2 deletions tests/install.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ chmod +x "$MOCK_PATH/curl" "$MOCK_PATH/chmod" "$MOCK_PATH/uname"

LAST_STATUS=0
CASE_DIR=""
INSTALL_DIR=""
OUTPUT_DIR=""
MOCK_RELEASE_JSON=""
MOCK_BINARY=""
Expand All @@ -34,7 +35,10 @@ new_case() {
MOCK_CHECKSUMS="${CASE_DIR}/checksums.txt"
MOCK_CURL_LOG="${CASE_DIR}/curl.log"
MOCK_CHMOD_LOG="${CASE_DIR}/chmod.log"
mkdir -p "$OUTPUT_DIR"
INSTALL_DIR="${CASE_DIR}/install"
mkdir -p "$OUTPUT_DIR" "$INSTALL_DIR"
cp "$REPO_ROOT/install.sh" "$REPO_ROOT/legacy-versions.txt" "$INSTALL_DIR/"
: >"${INSTALL_DIR}/cli-checksums.txt"
: >"$MOCK_CURL_LOG"
: >"$MOCK_CHMOD_LOG"
}
Expand Down Expand Up @@ -70,7 +74,7 @@ invoke_installer() {
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
bash "$INSTALL_DIR/install.sh" >"${CASE_DIR}/output.log" 2>&1
LAST_STATUS=$?
set -e
}
Expand Down Expand Up @@ -154,6 +158,32 @@ test_tampered_release() {
assert_not_installed
}

test_committed_checksum() {
new_case "committed-checksum"
printf 'committed-binary\n' >"$MOCK_BINARY"
printf 'v0.18.0 %s linear-release-linux-x64\n' "$(sha256 "$MOCK_BINARY")" >"${INSTALL_DIR}/cli-checksums.txt"
invoke_installer "v0.18.0" "Linux" "x86_64"
assert_success
assert_contains "${CASE_DIR}/output.log" "Verified SHA-256 checksum"
assert_contains "$MOCK_CURL_LOG" "/v0.18.0/linear-release-linux-x64"
if grep -Eq 'api.github.com|checksums.txt' "$MOCK_CURL_LOG"; then
echo "Committed checksum install made a metadata or checksums.txt request" >&2
command cat "$MOCK_CURL_LOG" >&2
return 1
fi
cmp "$MOCK_BINARY" "${OUTPUT_DIR}/linear-release"
}

test_committed_checksum_mismatch() {
new_case "committed-checksum-mismatch"
printf 'committed-binary\n' >"$MOCK_BINARY"
printf 'v0.18.0 %064d linear-release-linux-x64\n' 0 >"${INSTALL_DIR}/cli-checksums.txt"
invoke_installer "v0.18.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"
Expand All @@ -174,6 +204,17 @@ test_invalid_metadata() {
assert_not_installed
}

test_metadata_fetch_failure() {
new_case "metadata-fetch-failure"
printf 'verified-binary\n' >"$MOCK_BINARY"
: >"$MOCK_CHECKSUMS"
write_release "v0.17.0" true "linear-release-linux-x64" true
MOCK_CURL_FAIL_PATTERN="api.github.com" invoke_installer "v0.17.0" "Linux" "x86_64"
assert_failure
assert_contains "${CASE_DIR}/output.log" "Set github_token to a github.com token"
assert_not_installed
}

test_unsupported_platform() {
new_case "unsupported-platform"
printf 'binary\n' >"$MOCK_BINARY"
Expand All @@ -193,7 +234,10 @@ tests=(
test_legacy_latest
test_verified_release
test_tampered_release
test_committed_checksum
test_committed_checksum_mismatch
test_invalid_metadata
test_metadata_fetch_failure
test_unsupported_platform
)

Expand Down
Loading