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
13 changes: 13 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
# The changelog check diffs this branch against the base commit, which
# a shallow clone does not reach.
fetch-depth: 0

- name: Set up Go
uses: actions/setup-go@v7
Expand All @@ -33,6 +37,15 @@ jobs:
- name: Check the pinned Go toolchain is current
run: just toolchain-check

# Only on a pull request, where there is a base to diff against. Warns for
# the same reason toolchain-check does: plenty of Go changes are invisible
# to a user, and a red build for a refactor with nothing to say would train
# everyone to click past the one that matters. The release gate is what
# actually enforces the file.
- name: Check the changelog was updated
if: github.event_name == 'pull_request'
run: just changelog-check ${{ github.event.pull_request.base.sha }}

- name: Install golangci-lint
uses: golangci/golangci-lint-action@v9
with:
Expand Down
17 changes: 14 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ jobs:
steps:
- uses: actions/checkout@v7
with:
# goreleaser builds the changelog from the range between this tag and
# the previous one, which a shallow clone does not contain.
# goreleaser derives the release version from the tag history, which a
# shallow clone does not carry.
fetch-depth: 0

- name: Set up Go
Expand All @@ -32,10 +32,21 @@ jobs:
- name: Run end-to-end tests
run: just test-e2e

# The release body is the tag's section of CHANGELOG.md, nothing else. This
# fails when that section is missing or empty, which stops the release
# before a single artifact is published -- the recoverable moment to find
# out that a tag was cut without its entry.
#
# Not into dist/: `release --clean` deletes that directory before it reads
# anything, so a notes file written there would be gone by the time
# goreleaser looked for it.
- name: Extract the release notes from CHANGELOG.md
run: just release-notes "${{ github.ref_name }}" > "${{ runner.temp }}/release-notes.md"

- name: Publish the release
uses: goreleaser/goreleaser-action@v7
with:
version: '~> v2'
args: release --clean
args: release --clean --release-notes=${{ runner.temp }}/release-notes.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
10 changes: 10 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,20 @@ archives:
formats: [zip]
files:
- README.md
- CHANGELOG.md
- LICENSE

checksum:
name_template: checksums.txt

# The release body is CHANGELOG.md and nothing else. The release workflow passes
# the section for the tag as --release-notes, which makes this pipe unreachable:
# goreleaser loads the file and returns before generating anything.
#
# It stays, and stays enabled, because `disable: true` would throw the file away
# too and publish empty notes -- the flag is ignored when the pipe is disabled.
# What is left is the fallback for a `goreleaser release` run by hand without the
# flag, which produces the commit dump the changelog exists to replace. Use
# `just release-notes` instead.
changelog:
use: github
137 changes: 137 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Changelog

All notable changes to this project are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
While the major version is 0, a minor bump may carry a breaking change; those
are marked **Breaking** and listed first in their section.

## [Unreleased]

## [0.3.0] - 2026-08-11

### Added

- `--assert-status` accepts a status class (`2xx`), a comma-separated list, an
inclusive range (`500-503`), or any mix of the three: `301,2xx,500-503` is one
spec. An exact code behaves and reads exactly as before.
- Response bodies encoded with `br` or `zstd` are decoded before the body
assertions run. Previously the three body assertions refused both by name,
which is what a CDN in front of the endpoint actually serves.
- The verdict is coloured when stderr is a terminal, and the retry line is
yellow rather than dim. Redirected output is unchanged.

### Changed

- A status spec that can never match is rejected at the flag with exit 71,
rather than being accepted and failing an assertion at exit 93.
- Building from source now requires Go 1.26, and the build toolchain is pinned.
Release binaries need no Go toolchain at all.

### Fixed

- A failed header assertion writes values the way a person writes them --
`got "abc123"` for one, `got "first", "second"` for several -- instead of Go
slice syntax. `--assert-header`, `--assert-header-eq` and
`--assert-header-missing` were all affected. That a header assertion holds
when *any* value matches is now documented in both `--help` and the README.

## [0.2.0] - 2026-08-09

### Changed

- **Breaking:** exit codes now answer whose fault the failure is. `71` means the
invocation was rejected and no request was attempted, `92` means the request
produced no usable response, and `93` means a response arrived and at least one
assertion failed. Codes `91` and `103` are gone -- both are now `71` -- and
transport failures moved from `93` to `92`. Scripts that only test for
non-zero are unaffected; scripts that branch on a specific code must be
updated.
- **Breaking:** `-d` implies `POST`, as in curl, and sets
`Content-Type: application/x-www-form-urlencoded` when no `Content-Type`
header is given. An explicit `-X` always wins, even `-X GET`. A script that
passed `-d` and relied on the method staying `GET` changes behaviour.
- `--assert-body-empty=false` asserts the body is *not* empty, matching what
`--assert-ok=false` already did. It previously asserted nothing, and the run
died reporting that no assertion had been made.
- Verbosity conflicts resolve by an explicit priority instead of an undocumented
ladder: the command line beats the environment, and within one channel `-s`
beats `-v` beats `--log-level`. An overridden request is announced on stderr
rather than vanishing. Invocations that never conflicted are byte-identical.
- A repeated assertion flag is rejected instead of silently dropping the earlier
one. Assertion flags take a single value; `--assert-header` and `--assert-jq`
accumulate by design.

### Added

- `--assert-jq` runs a jq expression against a JSON body and passes when it
yields true, so an assertion survives reformatting, key reordering and
escaping that a regex over the serialized text does not. Repeating the flag
accumulates.
- `-L`/`--location` follows redirects and hands the final response to every
assertion. `--max-redirs` bounds the chain, defaulting to 10. Not following
remains the default, and `-L` is refused alongside `--assert-redirect` or
`--assert-redirect-eq`, which need the 3xx that following consumes.
- `--retry` re-sends a failed check until it passes, with `--retry-delay` between
attempts and `--retry-max-time` bounding the whole run. Any failure is
retried, an unreachable host and a wrong answer alike, which is the case a
deploy gate exists for.

### Fixed

- Response bodies are decoded before assertions run. Previously a compressed body
reached the body assertions as raw bytes whenever net/http had not decoded it
itself, so `--assert-body '.'` against a gzipped response reported `PASSED` for
a check it had never made.
- `--assert-body-eq ''` and patterns that match the empty string (`^$`, `.*`)
can pass. An emptiness guard meant to word the failure well had been deciding
the verdict, so a 204 could not be asserted to have the body a 204 is defined
to have.
- A `-H` value with no colon is rejected instead of accepted.
- The failure dump is formatted rather than serialized, whitespace-only bodies
are rendered as text, and the request body is shown.

## [0.1.0] - 2026-08-07

First release under `github.com/korya/http-assert`, with published binaries.

### Added

- `--version` reports the release, the commit and the Go toolchain. Asking the
binary what it was used to be an error.
- Static binaries for Linux, macOS and Windows on amd64 and arm64, with
checksums, attached to every release. The binary is CGO-free, so it drops into
a `scratch` or `distroless` image.

### Changed

- Environment configuration no longer goes through viper, which dropped 35 of
the module's 38 non-stdlib dependencies. Exactly six options read the
environment and the list is now explicit; previously the set was neither
documented nor consistent, and `HTTP_ASSERT_REQUEST` silently did nothing
while `HTTP_ASSERT_INSECURE` worked.
- An unparseable environment value is rejected with exit 71 and the parser's own
reason. Previously it was applied as the type's zero value, so
`HTTP_ASSERT_MAX_TIME=abc` disabled the request timeout entirely and
`HTTP_ASSERT_VERBOSE=yes` quietly turned verbosity off.

### Fixed

- An unparseable regular expression in `--assert-body`, `--assert-header` or
`--assert-redirect` is reported against the flag that carried it, instead of
crashing with a Go stack trace and exit code 2.
- `printPayload` no longer panics on a negative crop size. Not reachable through
the CLI; a latent defect rather than a live one.

## 0.0.7 and earlier

Releases up to and including 0.0.7 predate this changelog, the move to
Conventional Commits, and the transfer from `PlanitarInc`. See the
[git history](https://github.com/korya/http-assert/commits/v0.0.7) for that
period.

[Unreleased]: https://github.com/korya/http-assert/compare/v0.3.0...HEAD
[0.3.0]: https://github.com/korya/http-assert/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/korya/http-assert/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/korya/http-assert/compare/v0.0.7...v0.1.0
52 changes: 51 additions & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,56 @@ release-snapshot:
# tag, so it is safe to run at any point.
goreleaser release --snapshot --clean

[doc("Print the CHANGELOG.md section for a version, for use as release notes")]
release-notes version:
#!/usr/bin/env bash
# The release notes on GitHub are CHANGELOG.md and nothing else, so this is
# what stands between a curated section and goreleaser's commit dump. It
# prints to stdout; the caller decides where the file goes -- not dist/,
# which `goreleaser release --clean` deletes before it reads anything.
#
# Failing here is the point. A tag whose section is missing or empty stops
# the release before a single artifact is published, which is recoverable;
# a published release with empty notes is not.
set -euo pipefail
v="{{ version }}"; v="${v#v}"
section=$(awk -v v="$v" '
BEGIN { gsub(/\./, "\\.", v) }
$0 ~ "^## \\[" v "\\]" { p = 1; next }
p && /^## / { exit }
p && /^\[[^]]+\]: / { exit }
p { line[++n] = $0; if (NF) last = n }
END { for (i = 1; i <= last; i++) print line[i] }
' CHANGELOG.md | sed -e '/./,$!d')
if [ -z "$section" ]; then
echo "CHANGELOG.md has no entries under [$v]" >&2
exit 1
fi
printf '%s\n' "$section"

[doc("Warn if a branch changes Go sources without touching CHANGELOG.md")]
changelog-check base="origin/master":
#!/usr/bin/env bash
# A changelog that only mentions some of the changes is worse than none,
# because it still reads as the source of truth. Nothing else notices when
# an entry is forgotten -- the release gate fires far too late, when the
# tag is already cut.
#
# It warns rather than fails, like toolchain-check. Plenty of Go changes
# are genuinely invisible to a user, and a red build for a refactor teaches
# everyone to click through the one that matters.
set -uo pipefail
changed=$(git diff --name-only "{{ base }}...HEAD" 2>/dev/null || true)
if [ -z "$changed" ]; then
echo "no changes against {{ base }}; nothing to check"
exit 0
fi
echo "$changed" | grep -q '\.go$' || exit 0
echo "$changed" | grep -qx 'CHANGELOG.md' && exit 0
msg="Go sources changed but CHANGELOG.md did not; add an entry under [Unreleased] if this is user-visible"
[ -n "${GITHUB_ACTIONS:-}" ] && echo "::warning file=CHANGELOG.md::${msg}"
echo "$msg" >&2

[doc("Run every check CI runs, including the end-to-end suite")]
pre-push: pre-commit test-e2e

Expand All @@ -180,4 +230,4 @@ info:

[doc("Run security scan with gosec (if installed)")]
security:
gosec ./...
gosec ./...
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ different answer, the deviation is deliberate, and
- [Recipes](#recipes)
- [Reference](#reference): [environment variables](#environment-variables),
[exit codes](#exit-codes), [coming from curl](#coming-from-curl)
- [License](#license) · [Development](#development)
- [License](#license) · [Development](#development) · [Changelog](CHANGELOG.md)

## Installation

Expand All @@ -90,7 +90,7 @@ amd64 and arm64 are attached to every [release](https://github.com/korya/http-as

```bash
# Pick the latest tag from https://github.com/korya/http-assert/releases
VERSION=v0.1.0
VERSION=v0.3.0
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m | sed 's/x86_64/amd64/; s/aarch64/arm64/')
ARCHIVE="http-assert_${VERSION#v}_${OS}_${ARCH}.tar.gz"
Expand Down Expand Up @@ -672,3 +672,18 @@ just test-cover # merged unit + end-to-end coverage

The end-to-end tests are opt-in: `go test ./...` runs the unit tests only, and
`-e2e` (or the recipes above) switches the full suite on.

### Releasing

[`CHANGELOG.md`](CHANGELOG.md) *is* the release notes. The section for a tag
becomes the body of its GitHub release, so a tag whose section is missing or
empty fails the release before anything is published.

```bash
just release-notes v0.4.0 # preview exactly what the release will say
just release-snapshot # build every published target, publish nothing
```

To cut a release: retitle `[Unreleased]` as the new version with today's date,
add its compare link at the bottom of the file, bump `VERSION` in the download
snippet above, commit, and push the tag.
Loading