diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c0f1b6c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,93 @@ +name: Test formula + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + audit: + name: Audit and install (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest] + steps: + - uses: actions/checkout@v4 + + - uses: Homebrew/actions/setup-homebrew@master + + - name: Skip if formula not generated yet + id: guard + run: | + if [ ! -f Formula/hawkop.rb ]; then + echo "formula not generated yet — skipping" + echo "skip=1" >> "$GITHUB_OUTPUT" + else + echo "skip=0" >> "$GITHUB_OUTPUT" + fi + + - name: Register checkout as local tap + if: steps.guard.outputs.skip == '0' + run: | + # Modern `brew audit` and `brew install` require a tap-qualified name, + # not a file path. Symlink the checked-out repo into the Taps dir so + # `stackhawk/hawkop/hawkop` resolves to the formula in this branch. + tap_parent="$(brew --repository)/Library/Taps/stackhawk" + mkdir -p "$tap_parent" + ln -sfn "$GITHUB_WORKSPACE" "$tap_parent/homebrew-hawkop" + + - name: Audit + if: steps.guard.outputs.skip == '0' + run: brew audit --strict --online stackhawk/hawkop/hawkop + + - name: Probe tarball availability + if: steps.guard.outputs.skip == '0' + id: probe + run: | + # Pick the URL matching this runner's platform. + case "${{ runner.os }}-${{ runner.arch }}" in + macOS-ARM64) target="aarch64-apple-darwin" ;; + macOS-X64) target="x86_64-apple-darwin" ;; + Linux-X64) target="x86_64-unknown-linux-gnu" ;; + Linux-ARM64) target="aarch64-unknown-linux-gnu" ;; + *) echo "unsupported runner: ${{ runner.os }}-${{ runner.arch }}"; exit 1 ;; + esac + # Formula has no explicit `version` field — it's derived from the URL by + # Homebrew. Extract it from the first hawkop-v- pattern we see. + version=$(grep -oE 'hawkop-v[0-9][0-9A-Za-z.-]+' Formula/hawkop.rb | head -n1 | sed 's/^hawkop-v//' | sed -E 's/-(x86_64|aarch64).*$//') + url="https://download.stackhawk.com/hawkop/cli/hawkop-v${version}-${target}.tar.gz" + code=$(curl -sSIo /dev/null -w '%{http_code}' "$url") + echo "url=$url" + echo "code=$code" + echo "installable=$([ "$code" = "200" ] && echo 1 || echo 0)" >> "$GITHUB_OUTPUT" + if [ "$code" != "200" ] && [ "$code" != "404" ]; then + echo "unexpected HTTP $code from $url" >&2 + exit 1 + fi + + - name: Install + if: steps.guard.outputs.skip == '0' && steps.probe.outputs.installable == '1' + run: brew install --verbose stackhawk/hawkop/hawkop + + - name: Test + if: steps.guard.outputs.skip == '0' && steps.probe.outputs.installable == '1' + run: brew test stackhawk/hawkop/hawkop + + lint-scripts: + name: Shellcheck + bats + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install tools + run: | + sudo apt-get update + sudo apt-get install -y shellcheck bats + - name: Shellcheck + run: shellcheck scripts/update-formula.sh + - name: Bats + run: bats scripts/test-update-formula.bats diff --git a/.github/workflows/update-formula.yml b/.github/workflows/update-formula.yml new file mode 100644 index 0000000..6bd9de3 --- /dev/null +++ b/.github/workflows/update-formula.yml @@ -0,0 +1,68 @@ +name: Update formula + +on: + workflow_dispatch: + inputs: + version: + description: 'hawkop version to release (e.g. 0.6.2)' + required: true + type: string + repository_dispatch: + types: [hawkop-release] + +permissions: + contents: write + +concurrency: + group: update-formula + cancel-in-progress: false + +jobs: + update: + name: Render and commit Formula/hawkop.rb + runs-on: ubuntu-latest + steps: + - name: Resolve version + id: resolve + env: + INPUT_VERSION: ${{ github.event.inputs.version }} + PAYLOAD_VERSION: ${{ github.event.client_payload.version }} + run: | + if [ -n "$INPUT_VERSION" ]; then + v="$INPUT_VERSION" + else + v="$PAYLOAD_VERSION" + fi + if [ -z "$v" ]; then + echo "error: no version supplied (inputs.version or client_payload.version)" >&2 + exit 2 + fi + # Defense in depth — reject anything that isn't a semver before we hand it to any shell. + if ! printf '%s' "$v" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$'; then + echo "error: invalid version format '$v'" >&2 + exit 2 + fi + echo "version=$v" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v4 + + - uses: Homebrew/actions/setup-homebrew@master + + - name: Render formula + env: + HAWKOP_VERSION: ${{ steps.resolve.outputs.version }} + run: scripts/update-formula.sh --version "$HAWKOP_VERSION" + + - name: Commit and push + env: + HAWKOP_VERSION: ${{ steps.resolve.outputs.version }} + run: | + git config user.name "hawkop-bot" + git config user.email "noreply@stackhawk.com" + if [ -z "$(git status --porcelain Formula/hawkop.rb 2>/dev/null)" ]; then + echo "no changes to commit" + exit 0 + fi + git add Formula/hawkop.rb + git commit -m "hawkop: update to $HAWKOP_VERSION" + git push origin main diff --git a/Formula/.gitkeep b/Formula/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/Formula/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/README.md b/README.md index 410ac70..ff9335b 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,9 @@ brew tap stackhawk/hawkop brew install hawkop ``` +> Requires a published release. If you see `Error: No available formula`, the +> first release has not yet been cut — see [Releasing a new version](#releasing-a-new-version). + ## Upgrade ```sh @@ -22,3 +25,41 @@ brew upgrade hawkop brew uninstall hawkop brew untap stackhawk/hawkop ``` + +## Releasing a new version + +The formula is auto-generated. Do **not** edit `Formula/hawkop.rb` by hand. + +### Manual release + +```sh +gh workflow run update-formula.yml \ + -R stackhawk/homebrew-hawkop \ + -f version=X.Y.Z +``` + +This triggers the `update-formula` workflow, which renders +`scripts/formula-template.rb` using SHA256 sidecars from +`download.stackhawk.com/hawkop/cli/` and commits the result to `main`. + +### Automated release + +Upstream `stackhawk/hawkop` can send a `repository_dispatch` event of type +`hawkop-release` with payload `{"version": "X.Y.Z"}` to this repo to trigger +the same workflow. + +## Troubleshooting + +**`brew install` fails with a checksum mismatch.** +The tarball was re-uploaded with different content than the formula recorded. +Re-run the update workflow for the affected version. + +**`brew install` fails with HTTP 404.** +The formula is pinned to a version whose tarballs have not been published, +or the download bucket was rolled back. Check +`https://download.stackhawk.com/hawkop/cli/hawkop-v-.tar.gz` +with `curl -I` and coordinate with the `stackhawk/hawkop` release owner. + +**Shell renderer fails locally.** +Run `bats scripts/test-update-formula.bats` and +`shellcheck scripts/update-formula.sh` from the repo root. diff --git a/scripts/formula-template.rb b/scripts/formula-template.rb new file mode 100644 index 0000000..7390e16 --- /dev/null +++ b/scripts/formula-template.rb @@ -0,0 +1,38 @@ +# This file is rendered by scripts/update-formula.sh to produce Formula/hawkop.rb. +# Do not edit Formula/hawkop.rb by hand. +class Hawkop < Formula + desc "CLI companion for the StackHawk AppSec Intelligence Platform" + homepage "https://www.stackhawk.com/" + license "MIT" + + on_macos do + on_intel do + url "https://download.stackhawk.com/hawkop/cli/hawkop-v${version}-x86_64-apple-darwin.tar.gz" + sha256 "${mac_x64_sha256}" + end + on_arm do + url "https://download.stackhawk.com/hawkop/cli/hawkop-v${version}-aarch64-apple-darwin.tar.gz" + sha256 "${mac_arm64_sha256}" + end + end + + on_linux do + on_intel do + url "https://download.stackhawk.com/hawkop/cli/hawkop-v${version}-x86_64-unknown-linux-gnu.tar.gz" + sha256 "${linux_x64_sha256}" + end + on_arm do + url "https://download.stackhawk.com/hawkop/cli/hawkop-v${version}-aarch64-unknown-linux-gnu.tar.gz" + sha256 "${linux_arm64_sha256}" + end + end + + def install + bin.install "hawkop" + end + + test do + assert_match version.to_s, shell_output("#{bin}/hawkop --version") + system bin/"hawkop", "--help" + end +end diff --git a/scripts/test-update-formula.bats b/scripts/test-update-formula.bats new file mode 100755 index 0000000..005a408 --- /dev/null +++ b/scripts/test-update-formula.bats @@ -0,0 +1,80 @@ +#!/usr/bin/env bats + +setup() { + export SCRIPT="$BATS_TEST_DIRNAME/update-formula.sh" + export TMPDIR_TEST="$(mktemp -d)" + cd "$TMPDIR_TEST" + cp "$BATS_TEST_DIRNAME/formula-template.rb" . + mkdir -p scripts + cp "$BATS_TEST_DIRNAME/formula-template.rb" scripts/ +} + +teardown() { + rm -rf "$TMPDIR_TEST" +} + +@test "rejects missing --version" { + run "$SCRIPT" + [ "$status" -ne 0 ] + [[ "$output" == *"--version is required"* ]] +} + +@test "rejects invalid version format" { + run "$SCRIPT" --version "not-a-version" + [ "$status" -ne 0 ] + [[ "$output" == *"invalid version"* ]] +} + +@test "accepts semver" { + # Dry-run and offline mode so we don't hit the network + run "$SCRIPT" --version "1.2.3" --dry-run --offline + [ "$status" -eq 0 ] +} + +@test "accepts semver with prerelease suffix" { + run "$SCRIPT" --version "1.2.3-rc.1" --dry-run --offline + [ "$status" -eq 0 ] +} + +@test "extracts 64-char hex from sha256 sidecar" { + run bash -c "echo 'abc123$(printf "%.0s0" {1..58}) hawkop-v0.0.0-x86_64-apple-darwin.tar.gz' | '$SCRIPT' --parse-sha-stdin" + [ "$status" -eq 0 ] + [[ "$output" == abc123* ]] + [ ${#output} -eq 64 ] +} + +@test "rejects sidecar without 64-char hex" { + run bash -c "echo 'not-a-hash' | '$SCRIPT' --parse-sha-stdin" + [ "$status" -ne 0 ] + [[ "$output" == *"expected 64-char hex"* ]] +} + +@test "dry-run offline prints rendered formula to stdout" { + run "$SCRIPT" --version 1.2.3 --dry-run --offline + [ "$status" -eq 0 ] + [[ "$output" == *'class Hawkop < Formula'* ]] + # Version comes from the URL, not an explicit `version` field — Homebrew + # derives it from `hawkop-v-.tar.gz` to avoid `brew audit` + # flagging redundancy. + [[ "$output" == *'hawkop-v1.2.3-x86_64-apple-darwin.tar.gz'* ]] + [[ "$output" == *'hawkop-v1.2.3-aarch64-unknown-linux-gnu.tar.gz'* ]] + # No unsubstituted placeholders remain + [[ "$output" != *'${version}'* ]] + [[ "$output" != *'${mac_x64_sha256}'* ]] +} + +@test "writes Formula/hawkop.rb when not dry-run" { + run "$SCRIPT" --version 1.2.3 --offline + [ "$status" -eq 0 ] + [ -f "Formula/hawkop.rb" ] + run cat Formula/hawkop.rb + [[ "$output" == *'class Hawkop < Formula'* ]] +} + +@test "is idempotent — second run produces the same file" { + "$SCRIPT" --version 1.2.3 --offline + sum1=$(shasum -a 256 Formula/hawkop.rb | awk '{print $1}') + "$SCRIPT" --version 1.2.3 --offline + sum2=$(shasum -a 256 Formula/hawkop.rb | awk '{print $1}') + [ "$sum1" = "$sum2" ] +} diff --git a/scripts/update-formula.sh b/scripts/update-formula.sh new file mode 100755 index 0000000..5789719 --- /dev/null +++ b/scripts/update-formula.sh @@ -0,0 +1,123 @@ +#!/bin/sh +# Render scripts/formula-template.rb to Formula/hawkop.rb for a given hawkop version. +# Downloads each tarball from download.stackhawk.com and computes its SHA256. +# +# Usage: +# scripts/update-formula.sh --version 0.6.2 +# scripts/update-formula.sh --version 0.6.2 --dry-run +# scripts/update-formula.sh --version 0.6.2 --offline # skip network, use zeros +set -eu + +parse_sha_from_stdin() { + sha=$(tr -d '\r' | grep -Eo '[0-9a-fA-F]{64}' | head -n1) + if [ -z "$sha" ]; then + echo "error: expected 64-char hex on stdin" >&2 + return 1 + fi + printf '%s' "$sha" +} + +if [ "${1:-}" = "--parse-sha-stdin" ]; then + parse_sha_from_stdin + exit $? +fi + +VERSION="" +DRY_RUN=0 +OFFLINE=0 + +while [ $# -gt 0 ]; do + case "$1" in + --version) + if [ -z "${2:-}" ]; then + echo "error: --version requires an argument" >&2 + exit 2 + fi + VERSION="$2"; shift 2 + ;; + --dry-run) DRY_RUN=1; shift ;; + --offline) OFFLINE=1; shift ;; + -h|--help) sed -n '2,8p' "$0"; exit 0 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +if [ -z "$VERSION" ]; then + echo "error: --version is required" >&2 + exit 2 +fi + +if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$'; then + echo "error: invalid version '$VERSION' — expected semver like 1.2.3 or 1.2.3-rc.1" >&2 + exit 2 +fi + +BASE_URL="https://download.stackhawk.com/hawkop/cli" + +sha256_of_file() { + # Compute SHA256 of the given file. Prefer shasum (ships on macOS and most + # Linux distros); fall back to sha256sum. + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + echo "error: neither shasum nor sha256sum is available" >&2 + return 1 + fi +} + +fetch_sha() { + target="$1" + if [ "$OFFLINE" -eq 1 ]; then + printf '%064d' 0 + return 0 + fi + archive="hawkop-v${VERSION}-${target}.tar.gz" + tmpfile=$(mktemp) + trap 'rm -f "$tmpfile"' EXIT INT TERM + if ! curl -sSfL -o "$tmpfile" "${BASE_URL}/${archive}"; then + echo "error: failed to download ${archive} from ${BASE_URL}" >&2 + exit 1 + fi + sha=$(sha256_of_file "$tmpfile") + rm -f "$tmpfile" + trap - EXIT INT TERM + if [ -z "$sha" ] || [ "${#sha}" -ne 64 ]; then + echo "error: got unexpected hash for ${archive}: '$sha'" >&2 + exit 1 + fi + printf '%s' "$sha" +} + +MAC_X64_SHA=$(fetch_sha "x86_64-apple-darwin") +MAC_ARM64_SHA=$(fetch_sha "aarch64-apple-darwin") +LINUX_X64_SHA=$(fetch_sha "x86_64-unknown-linux-gnu") +LINUX_ARM64_SHA=$(fetch_sha "aarch64-unknown-linux-gnu") + +# --- Render template --- +# Resolve template path relative to this script. +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEMPLATE="${SCRIPT_DIR}/formula-template.rb" +if [ ! -f "$TEMPLATE" ]; then + echo "error: template not found at $TEMPLATE" >&2 + exit 1 +fi + +render() { + sed \ + -e "s|\${version}|${VERSION}|g" \ + -e "s|\${mac_x64_sha256}|${MAC_X64_SHA}|g" \ + -e "s|\${mac_arm64_sha256}|${MAC_ARM64_SHA}|g" \ + -e "s|\${linux_x64_sha256}|${LINUX_X64_SHA}|g" \ + -e "s|\${linux_arm64_sha256}|${LINUX_ARM64_SHA}|g" \ + "$TEMPLATE" +} + +if [ "$DRY_RUN" -eq 1 ]; then + render +else + mkdir -p Formula + render > Formula/hawkop.rb + echo "wrote Formula/hawkop.rb (version $VERSION)" >&2 +fi