diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96070dd..6e240f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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: @@ -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 diff --git a/README.md b/README.md index 5f5a774..12e6683 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/RELEASING.md b/RELEASING.md index e28fe28..3b6496c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -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` tag and `release/` branch don't already exist 5. Creates a `release/` 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` diff --git a/action.yml b/action.yml index 900bfaa..f818c2c 100644 --- a/action.yml +++ b/action.yml @@ -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: diff --git a/cli-checksums.txt b/cli-checksums.txt new file mode 100644 index 0000000..963efd4 --- /dev/null +++ b/cli-checksums.txt @@ -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 diff --git a/install.sh b/install.sh index 458844f..aac4d8c 100644 --- a/install.sh +++ b/install.sh @@ -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() { @@ -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() { @@ -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 @@ -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 @@ -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." @@ -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" @@ -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" diff --git a/scripts/release.sh b/scripts/release.sh index ba53fe7..b146a82 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -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" @@ -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 --- diff --git a/scripts/verify-checksums.sh b/scripts/verify-checksums.sh new file mode 100755 index 0000000..0c6086c --- /dev/null +++ b/scripts/verify-checksums.sh @@ -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' "${INSTALL_DIR}/cli-checksums.txt" : >"$MOCK_CURL_LOG" : >"$MOCK_CHMOD_LOG" } @@ -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 } @@ -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" @@ -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" @@ -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 )