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
122 changes: 44 additions & 78 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,109 +1,75 @@
name: release

# Cuts a GitHub Release on tag push: GoReleaser cross-compiles the binaries,
# archives one tarball per OS/arch (zip on Windows), emits SHA256SUMS, attaches
# install.sh, and regenerates the Homebrew cask in chenchaoyi/homebrew-tap.
# The asset names are kept identical to past releases so the `curl | sh`
# installer and the built-in `hammer update` keep working unchanged.
#
# Trigger:
# - push of a tag matching `v*` → the real release (+ tap update)
# - manual workflow_dispatch → a snapshot dry-run (artifacts only, no
# release, no tap push) for validation

on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Release tag to create (e.g. v1.1.0)'
required: true

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

permissions:
contents: write

jobs:
build:
goreleaser:
name: Build and release binaries
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # GoReleaser needs full history for the changelog

- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true

- name: Resolve version
id: version
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ inputs.version }}"
else
VERSION="${GITHUB_REF#refs/tags/}"
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Releasing ${VERSION}"

- name: Run tests
run: go test -race ./...

- name: Build cross-platform binaries
run: |
set -euo pipefail
VERSION="${{ steps.version.outputs.version }}"
mkdir -p dist

targets=(
linux/amd64
linux/arm64
darwin/amd64
darwin/arm64
windows/amd64
)
for target in "${targets[@]}"; do
GOOS="${target%/*}"
GOARCH="${target#*/}"
ext=""
[ "$GOOS" = "windows" ] && ext=".exe"
bin="hammer-${GOOS}-${GOARCH}${ext}"
echo "Building $bin"
GOOS="$GOOS" GOARCH="$GOARCH" CGO_ENABLED=0 go build \
-trimpath \
-ldflags="-s -w -X main.version=${VERSION}" \
-o "dist/${bin}" .
done
- name: GoReleaser (release on tag)
if: startsWith(github.ref, 'refs/tags/')
uses: goreleaser/goreleaser-action@v7
with:
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Pushes the regenerated cask to chenchaoyi/homebrew-tap. The default
# GITHUB_TOKEN can't write to another repo, so this must be a PAT
# (classic, `repo` scope) stored as the HOMEBREW_TAP_TOKEN secret.
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}

- name: Package archives and checksums
run: |
set -euo pipefail
cd dist
cp ../install.sh install.sh
chmod 0755 install.sh
for bin in hammer-*; do
case "$bin" in
*.exe)
base="${bin%.exe}"
mkdir -p package
cp "$bin" package/hammer.exe
(cd package && zip "../${base}.zip" hammer.exe)
rm -rf package
;;
*.zip|*.tar.gz|SHA256SUMS)
continue
;;
*)
mkdir -p package
cp "$bin" package/hammer
tar -czf "${bin}.tar.gz" -C package hammer
rm -rf package
;;
esac
done
rm -f hammer-*-amd64 hammer-*-arm64 hammer-*.exe
sha256sum *.tar.gz *.zip install.sh > SHA256SUMS
ls -lh
- name: GoReleaser (snapshot dry-run on dispatch)
if: github.event_name == 'workflow_dispatch'
uses: goreleaser/goreleaser-action@v7
with:
version: '~> v2'
args: release --snapshot --clean --skip=publish
env:
HOMEBREW_TAP_TOKEN: ""

- name: Create release
uses: softprops/action-gh-release@v3
- name: Upload snapshot artifacts (dispatch only)
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v6
with:
tag_name: ${{ steps.version.outputs.version }}
files: |
name: hammer-snapshot
path: |
dist/*.tar.gz
dist/*.zip
dist/install.sh
dist/SHA256SUMS
generate_release_notes: true
fail_on_unmatched_files: true
retention-days: 14
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@ _testmain.go

# macOS
.DS_Store

# GoReleaser build output
/dist/
113 changes: 113 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# GoReleaser config for hammer.
#
# Produces the SAME release assets the previous hand-rolled workflow did, so the
# `curl | sh` installer (install.sh) and the built-in `hammer update` keep
# working unchanged:
#
# hammer-<os>-<arch>.tar.gz (linux/darwin)
# hammer-<os>-<arch>.zip (windows)
# SHA256SUMS (sha256sum format: "<hash> <name>")
# install.sh (attached to the release)
#
# On a version tag push, .github/workflows/release.yml runs `goreleaser release`,
# which also regenerates the Homebrew formula in chenchaoyi/homebrew-tap.
version: 2

project_name: hammer

before:
hooks:
- go mod tidy

builds:
- id: hammer
main: .
binary: hammer
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ignore:
# The previous workflow shipped windows/amd64 only.
- goos: windows
goarch: arm64
flags:
- -trimpath
ldflags:
- -s -w -X main.version={{ .Version }}

archives:
- id: hammer
# hammer-<os>-<arch>.tar.gz — install.sh and `hammer update` build this
# exact name (no version segment), so keep it stable.
name_template: "hammer-{{ .Os }}-{{ .Arch }}"
formats: [tar.gz]
format_overrides:
- goos: windows
formats: [zip]
files:
- LICENSE
- README.md

checksum:
# install.sh fetches SHA256SUMS and verifies the archive against it; the
# updater does the same. Keep the filename and sha256 algorithm stable.
name_template: "SHA256SUMS"
algorithm: sha256
extra_files:
# Keep install.sh's own checksum in SHA256SUMS, matching prior releases.
- glob: ./install.sh

release:
github:
owner: chenchaoyi
name: hammer
draft: false
prerelease: auto
# Attach the installer so `curl .../install.sh | sh` keeps resolving.
extra_files:
- glob: ./install.sh

changelog:
use: github
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^chore:"
- "^ci:"

# Homebrew distribution. GoReleaser deprecated `brews` (formulae) in favor of
# `homebrew_casks`; casks are macOS-only, so macOS users get `brew install
# hammer` while Linux users keep the curl installer above (no regression).
homebrew_casks:
- name: hammer
binaries:
- hammer
repository:
owner: chenchaoyi
name: homebrew-tap
branch: main
# Cross-repo push needs a PAT; the default GITHUB_TOKEN can't write to
# another repo. Set HOMEBREW_TAP_TOKEN in this repo's Actions secrets.
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
directory: Casks
homepage: "https://github.com/chenchaoyi/hammer"
description: "Lightweight, agent-friendly HTTP(S) load generator"
commit_author:
name: chenchaoyi
email: ccy.chenchaoyi@gmail.com
# The release binaries are unsigned; strip the quarantine bit so the CLI
# runs without a Gatekeeper prompt after `brew install`.
hooks:
post:
install: |
if OS.mac?
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/hammer"]
end
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,23 @@ driven by humans **and AI agents** alike.

## Install

### Homebrew (macOS)

```shell
brew install chenchaoyi/tap/hammer
hammer -version
```

Or tap once and install by name:

```shell
brew tap chenchaoyi/tap
brew install hammer
```

Upgrade with `brew upgrade hammer`. (Homebrew casks are macOS-only; on Linux use
the install script below or `hammer update`.)

### Install script (recommended)

Install the latest GitHub release as a `hammer` command:
Expand Down
25 changes: 21 additions & 4 deletions install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,20 +169,37 @@ func TestInstallDocsUseReleaseCurlOneLiner(t *testing.T) {
}
}

// Releases are cut by GoReleaser; the curl one-liner depends on install.sh
// being attached to every release. GoReleaser does that via release.extra_files
// in .goreleaser.yaml, so assert the installer is wired in there.
func TestReleaseWorkflowPublishesInstaller(t *testing.T) {
data, err := os.ReadFile(".github/workflows/release.yml")
data, err := os.ReadFile(".goreleaser.yaml")
if err != nil {
t.Fatal(err)
}
text := string(data)
if !strings.Contains(text, "./install.sh") {
t.Fatal(".goreleaser.yaml does not publish install.sh (release.extra_files)")
}
// The archive/checksum names must stay stable so install.sh and
// `hammer update` keep resolving them.
for _, want := range []string{
"cp ../install.sh install.sh",
"dist/install.sh",
`name_template: "hammer-{{ .Os }}-{{ .Arch }}"`,
`name_template: "SHA256SUMS"`,
} {
if !strings.Contains(text, want) {
t.Fatalf("release workflow missing %q", want)
t.Fatalf(".goreleaser.yaml missing stable asset name %q", want)
}
}

// And the workflow must actually invoke GoReleaser.
wf, err := os.ReadFile(".github/workflows/release.yml")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(wf), "goreleaser/goreleaser-action") {
t.Fatal("release workflow no longer runs goreleaser-action")
}
}

func writeFakeHammerArchive(path string) error {
Expand Down