diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 25b0b18..904a3c0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 @@ -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: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3c7a61..cc71086 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 4894044..9d8b997 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..babe02d --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/Justfile b/Justfile index db9fb51..d4061c6 100644 --- a/Justfile +++ b/Justfile @@ -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 @@ -180,4 +230,4 @@ info: [doc("Run security scan with gosec (if installed)")] security: - gosec ./... \ No newline at end of file + gosec ./... diff --git a/README.md b/README.md index 4a53503..e401dbe 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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" @@ -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.