From 61048d8da13cd56511ccf4b34cb3407d404ef55f Mon Sep 17 00:00:00 2001 From: korya <148461+korya@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:27:42 -0400 Subject: [PATCH 1/3] docs(readme): Point the download snippet at the current release The snippet pinned VERSION=v0.1.0, two releases behind, so a reader following the install instructions verbatim downloaded a binary without --assert-jq, -L, --retry or brotli support, and with the pre-v0.2.0 exit codes. The line names a tag rather than resolving the latest one on purpose: the comment above it already tells the reader to pick their own, and resolving it would mean parsing the GitHub API with jq, in a README whose argument is that you should not need jq to check an endpoint. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MxhyKzFVP99To7pYTxMyua --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4a53503..89534a4 100644 --- a/README.md +++ b/README.md @@ -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" From 127483e5b9e2cf40810b076739b2a989048ec47c Mon Sep 17 00:00:00 2001 From: korya <148461+korya@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:27:55 -0400 Subject: [PATCH 2/3] docs(changelog): Add a changelog, backfilled to v0.1.0 Release notes were GitHub's generated commit list, which is what this tool's users are least able to act on. Upgrading from v0.1.0 to v0.2.0 meant reading 39 commit subjects, 25 of them docs, chore or test, to find the one line that mattered: exit codes 91 and 103 are gone and transport failures moved from 93 to 92. A tool whose whole contract is its exit codes had buried a change to them at position 23 of 39. The format is Keep a Changelog 1.1.0. Its two categories a commit type cannot express -- Deprecated and Security -- are the two a CLI needs most, and dropping every docs, chore, test and refactor commit removes 63% of the history as noise. Backfilled to v0.1.0 only. Everything up to v0.0.7 predates Conventional Commits and the transfer from PlanitarInc: 48 commits that are mostly merges, Dependabot bumps and four "Update README.md"s, from which honest user-facing entries cannot now be recovered. Reconstructing them would invent prose nobody can verify, which is the inconsistency the format warns against, so that period is named and pointed at the git history instead. No Security section. The one candidate was a Dependabot bump of the indirect golang.org/x/net, which names no CVE; writing "Security" over an unverified vulnerability is worse than silence. The dependency reduction that is substantiated -- viper's removal, 35 of 38 non-stdlib dependencies -- is recorded under v0.1.0 as a change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MxhyKzFVP99To7pYTxMyua --- CHANGELOG.md | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 +- 2 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md 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/README.md b/README.md index 89534a4..cc1777d 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 From 2311975022f42da024bae411dd9d34e4efd3a7a1 Mon Sep 17 00:00:00 2001 From: korya <148461+korya@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:28:09 -0400 Subject: [PATCH 3/3] chore(release): Publish the changelog section as the release notes CHANGELOG.md is now the release body, so the curated file and the published notes cannot disagree. `just release-notes ` prints one section and goreleaser takes it verbatim through --release-notes, which short-circuits its own generation. A changelog that is merely decorative rots, and the format's own warning is that a changelog mentioning only some changes is more dangerous than none, because it still reads as the source of truth. Two things hold it current. The release extraction fails when the tag's section is missing or empty, which stops the release before any artifact is published; that is the moment a forgotten entry is still cheap to fix, unlike a tag already on the module proxy. And a pull request touching Go sources without touching CHANGELOG.md gets a warning, not a red build, for the reason toolchain-check warns: plenty of Go changes are genuinely invisible to a user, and failing on those would train everyone to click past the one that matters. Two goreleaser details drove the shape. `changelog.disable: true` looks like the way to stop the commit dump and is not -- it discards a --release-notes file too and publishes empty notes -- so the pipe stays enabled and unreachable, documented in place. And the notes file cannot live in dist/, because `release --clean` removes that directory before it reads anything; it goes to the runner's temp directory instead. Verified against the real pipeline at v0.3.0 with publishing skipped: goreleaser logs `loading file` for the extracted section and generates nothing of its own. CHANGELOG.md now also ships inside every release archive. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MxhyKzFVP99To7pYTxMyua --- .github/workflows/build.yml | 13 +++++++++ .github/workflows/release.yml | 17 ++++++++++-- .goreleaser.yaml | 10 +++++++ Justfile | 52 ++++++++++++++++++++++++++++++++++- README.md | 15 ++++++++++ 5 files changed, 103 insertions(+), 4 deletions(-) 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/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 cc1777d..e401dbe 100644 --- a/README.md +++ b/README.md @@ -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.