Skip to content

chore(go): upgrade to Go 1.27 - #4105

Closed
aryanmehrotra wants to merge 9 commits into
developmentfrom
chore/go-1.27
Closed

chore(go): upgrade to Go 1.27#4105
aryanmehrotra wants to merge 9 commits into
developmentfrom
chore/go-1.27

Conversation

@aryanmehrotra

Copy link
Copy Markdown
Member

Moves the repo to Go 1.27: every module's go directive (32 go.mod files plus go.work), the CI matrix, the golang: base images, and the docs that name a Go version.

Two CI pins had to move with it, and three characterization tests changed. Everything below was verified locally on go1.27.0 against go1.26.5.

golangci-lint v2.12.2 → v2.13.2 — required, not housekeeping

v2.12.2 is built with go1.25 and cannot analyse a Go 1.27 toolchain at all. Both halves fail:

Setup Result
v2.12.2, go.mod at 1.26, toolchain go1.27 typecheck errors inside the standard librarycould not import math/rand/v2 (… method must have no type parameters), because 1.27 added generic methods and its go/types cannot parse them
v2.12.2, go.mod at 1.27 refuses to start: can't load config: the Go language version (go1.26) used to build golangci-lint is lower than the targeted Go version (1.27.0)

v2.13.2 (2026-08-27) is the first release built on go1.27.

The bump is version-neutral, which is the part worth checking: running v2.13.2 over the root module on go1.26 and again on go1.27 produces the identical 46 issues. Go 1.27 introduces no new lint findings — the linter bump is purely about being able to run.

setup-go '1.26' → '1.27' in the three lint/quality jobs

code_quality, lint_changed_submodules and linting_party do not go through .github/actions/setup-go-toolchain, so GOTOOLCHAIN stays local. Against a go-1.27 module they stop dead at go mod download:

go: go.mod requires go >= 1.27.0 (running go 1.26.5; GOTOOLCHAIN=local)

The test matrix shifts ['1.26','1.25','1.24']['1.27','1.26','1.25'], and the two coverage-upload guards move with it (matrix.go-version == '1.24''1.25') so parse_coverage and upload_coverage still find their artifacts.

Three tests changed — all one cause

Go 1.27 re-implements encoding/json on top of encoding/json/v2. GOEXPERIMENT=nojsonv2 makes all three pass again, which is what identifies this as a stdlib wire-format change rather than a behaviour regression in GoFr.

Invalid UTF-8 is still replaced by U+FFFD, but emitted as a raw UTF-8 rune instead of a six-byte escape. Same decoded string, different wire bytes — and Test_LogWireFormat_HTMLEscapingOnTheRealPath and Test_LoggingContract_RequestLogEscaping exist specifically to pin wire bytes, so they now pin the 1.27 form.

UnmarshalTypeError no longer prefixes the Go type name onto the field path, so field charBindTarget.a became field .a in TestRequest_Char_BindJSON. The error's classification is unchanged; only its text is.

Each site carries a comment recording the change and the nojsonv2 escape hatch. This is user-visible: anyone asserting on GoFr log bytes or on bind-error strings will see the same shift.

Verification

Check Result
go build ./..., all 33 modules clean
go vet ./..., all 33 modules clean
go mod tidy -diff across pkg/ clean (all modules already have the two require blocks 1.27's tidy wants)
gofmt on changed files clean
./pkg/gofr/... pass
all 27 pkg/ submodules pass
./examples/... pass against live MySQL, Redis and Zipkin
examples/using-s3-filestore, using-add-filestore pass
.github/scripts/check-binaries.sh pass

Not verified locally: examples/using-subscriber, which needs Kafka. It fails identically on go1.26 and go1.27 here (TestMainInitialization, 30s timeout, no broker), so the failure is environmental — but CI's Kafka service is what will actually confirm it.

One flake seen and dismissed: TestCircuitBreaker_MixedHTTPMethods asserts wall-clock < 2s and returned 2.021s once during a heavily loaded parallel sweep. In isolation it passes 8/8 on go1.27 and 6/6 on go1.26. Pre-existing timing margin, untouched here.

Moves every module to `go 1.27.0` (32 go.mod files plus go.work), shifts the
CI matrix to 1.27/1.26/1.25, and bumps the images and docs that name a Go
version.

golangci-lint had to move with it. v2.12.2 is built with go1.25 and cannot
analyse a 1.27 toolchain at all: against a go-1.26 module it fails typecheck
inside the standard library itself (`math/rand/v2` now uses a generic method,
which its go/types cannot parse), and against a go-1.27 module it refuses to
start — "the Go language version (go1.26) used to build golangci-lint is lower
than the targeted Go version (1.27.0)". v2.13.2 is the first release built on
go1.27. Running both linters over the root module confirms the bump is
version-neutral: v2.13.2 reports the identical 46 issues on go1.26 and on
go1.27, so Go 1.27 itself introduces no new findings.

The three lint/quality jobs also had to leave setup-go '1.26'. They do not use
.github/actions/setup-go-toolchain, so GOTOOLCHAIN stays `local` and a go-1.27
module stops them dead at `go mod download` with "go.mod requires go >= 1.27.0
(running go 1.26.5; GOTOOLCHAIN=local)".

Three characterization tests changed, all from one cause: Go 1.27 re-implements
encoding/json on top of encoding/json/v2. GOEXPERIMENT=nojsonv2 makes all three
pass again, which is what identifies the cause rather than a behaviour
regression in GoFr.

  - Invalid UTF-8 is still replaced by U+FFFD, but emitted as a raw UTF-8 rune
    instead of a six-byte backslash-u escape. Same decoded string, different
    wire bytes — and these two tests exist to pin wire bytes, so they now pin
    the 1.27 form.
  - UnmarshalTypeError no longer prefixes the Go type name onto the field path,
    so "field charBindTarget.a" became "field .a". The error's classification
    is unchanged; only its text is.

Nothing else moved. `go build` and `go vet` are clean across all 33 modules,
`go mod tidy -diff` is clean across pkg/, and the pkg, submodule and example
suites pass on 1.27 — the examples against live MySQL, Redis and Zipkin.
@aryanmehrotra

Copy link
Copy Markdown
Member Author

Self-review

Branch is now up to date with development (0 behind; the merge brought in #4108 and #4109, and both packages were re-tested on the real 1.27 toolchain: pkg/gofr/ai/..., pkg/gofr/container, pkg/gofr/http/middleware all green).

Everything below came out of actually running the json paths under 1.27 rather than reading the diff.

🔴 The two error-string changes are masking a real regression in Bind

request_test.go relaxes two expectations from field charBindTarget.a to field .a, and the comment explains it as json/v2 no longer prefixing the Go type name. That is not what json/v2 does. It still prefixes the type name — GoFr loses it because Bind passes a pointer to an interface:

// pkg/gofr/http/request.go:93
return json.Unmarshal(body, &i)   // i is already a pointer; &i makes it **any

Measured on go1.27.0 darwin/arm64, same struct, same input:

call shape go 1.26 go 1.27
json.Unmarshal(body, &i) — what GoFr does field charBindTarget.a field .a
json.Unmarshal(body, i)i is already a pointer field charBindTarget.a field charBindTarget.a

So this is not a cosmetic upstream rename. On Go 1.27 every GoFr user's bind error loses the struct name — json: cannot unmarshal number into Go struct field .a of type string — and this PR bakes that degradation into the test as if it were expected.

The & is redundant: Bind already rejects a non-pointer at request.go:80, so i is guaranteed to be a pointer by the time line 93 runs. Dropping it restores the message on 1.27 and leaves 1.26 unchanged, and both test expectations can then stay as they are.

The &i itself is ancient (fb585c15d, "Db utility (#10)") — harmless until 1.27, which is why nothing caught it. I'd rather fix it here than ship a worse error message with the toolchain bump.

🟡 The version matrix no longer tests three versions

go-version: ['1.27','1.26','1.25'], but go.work says go 1.27.0 and .github/actions/setup-go-toolchain deliberately installs whatever go.work asks for and sets GOTOOLCHAIN=auto. So the 1.25 and 1.26 legs upgrade themselves to 1.27 and every leg tests the same toolchain.

That was already true before this PR, but it matters more now: the tests changed in this PR pin 1.27-only wire bytes, so a leg that genuinely ran 1.26 would fail. Either the older legs should be dropped, or they need GOTOOLCHAIN=local and a build tag, and the "lowest matrix Go version" comment on the coverage upload should say which version actually produces it.

🟡 The two wire-format tests pin bytes that depend on the consuming module

The escaped-vs-raw U+FFFD difference is gated by the main module's go directive, not by the toolchain:

main module go directive json.Marshal of invalid UTF-8
go 1.26.0 {"uri":"/��/ok"}
go 1.27.0 raw U+FFFD bytes

GOEXPERIMENT=nojsonv2 reverts it, as the comments say — worth noting GODEBUG=jsonv2=0 does not, so that lever is the right one to document. Since these tests exist to pin GoFr's log wire format, it is worth saying explicitly in the comment that the format follows the application's own go line, not GoFr's.

🔵 Scope

golangci-lint v2.12.2 → v2.13.2 rides along in three places. Almost certainly needed to parse 1.27, but it is an unrelated version bump inside a Go-upgrade PR and is not mentioned in the description.

✅ What checks out

  • All 32 go.mod files are on 1.27.0 — no stragglers, which is the usual failure mode for this change.
  • go.work and the root Dockerfile moved with them.
  • Docs corrected rather than merely bumped: README.md claimed 1.24 while go.mod already required 1.26, and docs/AGENTS.md claimed 1.25. Both are now accurate.
  • The coverage-upload condition was moved off the dropped 1.24 leg instead of being left dangling.
  • go build ./... clean and the full ./pkg/... suite green locally on go1.27.0.

Suggested follow-up before merge

  1. Change request.go:93 to json.Unmarshal(body, i) and revert the two expectations in request_test.go.
  2. Correct the comment above TestRequest_Char_BindJSON — the cause is the pointer-to-interface, not a json/v2 rename.
  3. Decide on the matrix: three labels that all run 1.27 is worse than one honest label.

Bind unmarshalled through a pointer to an interface:

    if rv := reflect.ValueOf(i); rv.Kind() != reflect.Pointer {
        return errNonPointerBind
    }
    ...
    return json.Unmarshal(body, &i)   // i is already a pointer; &i is **any

The guard above already rejects anything that is not a pointer, so the
extra & was redundant. It was also harmless until Go 1.27 re-implemented
encoding/json on top of encoding/json/v2, which renders the struct name
from the value it is handed. Through the double indirection the name is
lost, so a wrong-typed field degraded for every GoFr user:

    go 1.26:  json: cannot unmarshal number into Go struct field charBindTarget.a of type string
    go 1.27:  json: cannot unmarshal number into Go struct field .a of type string

This is not a json/v2 rendering change — passing the pointer directly
keeps the struct name on 1.27, which is what this commit does:

    | call shape                    | go 1.26          | go 1.27          |
    |-------------------------------|------------------|------------------|
    | json.Unmarshal(body, &i)      | charBindTarget.a | .a               |
    | json.Unmarshal(body, i)       | charBindTarget.a | charBindTarget.a |

The two expectations in TestRequest_Char_BindJSON go back to naming the
struct, and the comment claiming json/v2 dropped the prefix is removed,
since that was the wrong diagnosis.

Behaviour is otherwise unchanged. Every other bind shape was differenced
between the two forms — valid struct, malformed body, empty body, null,
unknown field, pointer to map / slice / []byte / any — and all are
identical. The one further difference is a typed nil pointer, which the
old form silently accepted and bound nothing into, and which now reports
json: Unmarshal(nil *T); no test covered it, and a handler that binds a
nil pointer would nil-deref on the next line regardless.

Verified end to end against a real GoFr server, not just in unit tests:

    POST /bind {"a":5}
      before  500  "...Go struct field .a of type string"
      after   500  "...Go struct field charBindTarget.a of type string"

No performance cost — one less indirection is two fewer allocations:

    BenchmarkBindJSON   &i:  6355 B/op  24 allocs/op
    BenchmarkBindJSON    i:  6335 B/op  22 allocs/op
@aryanmehrotra

Copy link
Copy Markdown
Member Author

Acted on the first finding — Bind keeps its error message, and gets slightly faster

Pushed c041fe2a. request.go now unmarshals into the pointer it was given instead of a pointer to the interface holding it, and the two expectations in TestRequest_Char_BindJSON go back to naming the struct. The comment blaming json/v2 for the rename is gone, since that was the wrong diagnosis.

Behaviour is preserved, not just for the two cases in the test

Every bind shape was differenced between the two call forms on go1.27.0 — valid struct, malformed body, empty body, null, unknown field, pointer to map / slice / []byte / any. All identical. Only two differ:

case &i (before) i (after)
wrong-typed field field .a field charBindTarget.a
typed nil pointer nil, binds nothing json: Unmarshal(nil *T)

The second is worth a look, though I think it is fine: no test covered it, and a handler that binds a typed nil pointer would nil-deref on the next line anyway. Say the word if you would rather preserve the silent no-op and I will guard it explicitly.

Confirmed against a running server, not only in unit tests

Built two binaries from this branch — one with &i, one without — and ran each as a real GoFr service against this worktree's sbx sandbox (gofr-go127: MySQL, Redis, Zipkin; all three went asleepawake, so the app genuinely connected). Same requests to each:

POST /bind  {"a":"hello","b":42}
  before  201  {"data":{"a":"hello","b":42}}
  after   201  {"data":{"a":"hello","b":42}}          identical

POST /bind  {"a":5}
  before  500  json: cannot unmarshal number into Go struct field .a of type string
  after   500  json: cannot unmarshal number into Go struct field charBindTarget.a of type string

POST /bind  {"a":"x","b":"nope"}
  after   500  json: cannot unmarshal string into Go struct field charBindTarget.b of type int

Malformed body, empty body, null, unknown field and /.well-known/alive all responded identically across the two binaries.

No performance cost

Removing the indirection removes two allocations per bind:

B/op allocs/op
json.Unmarshal(body, &i) 6355 24
json.Unmarshal(body, i) 6335 22

-benchtime 3000x -count 6; ns/op overlaps between the two, allocation counts are stable.

Still open from the review

  • The version matrix labels 1.25 / 1.26 / 1.27 but every leg resolves to 1.27 via go.work + setup-go-toolchain.
  • The golangci-lint v2.12.2 → v2.13.2 bump is unmentioned in the description. Worth keeping — a v2.12.2 binary refuses a 1.27 module outright: the Go language version (go1.26) used to build golangci-lint is lower than the targeted Go version (1.27.0). That is why it has to move in the same PR.

Unmarshalling into the pointer rather than into a pointer to the interface
recovered the struct name in the error, but it also changed one case the
previous form silently allowed: a typed nil pointer.

The old code decoded into the local interface, which discarded the static
type, so for a nil pointer json decoded into a fresh generic value and the
caller's pointer was left alone. Syntax errors still surfaced; type errors
never did. Passing the pointer straight through instead reports
json: Unmarshal(nil *T) for every body.

Differencing every target shape against GoFr on Go 1.26 showed the reach
was wider than the single case first spotted -- a nil pointer swallowed
wrong-type, nested-wrong-type, array-into-struct, bare number and bare
string alike, all of which had started to error.

The nil case is now routed to a throwaway interface, reproducing the old
behaviour exactly rather than approximately. reflect.Value is hoisted out
of the existing guard, so the check costs nothing on the hot path.

Differential across 10 target shapes x 12 bodies, GoFr on Go 1.26 vs this
branch on Go 1.27:

    upgrade alone (&i)          7 of 120 rows differ
    upgrade + pointer fix      10 of 120 rows differ
    upgrade + fix + nil guard   5 of 120 rows differ

The remaining 5 are upstream and not reachable from GoFr: a []byte target
gains a message prefix on invalid base64, a nested field is named after
the outer struct rather than the inner one (T.i.z, was Inner.i.z), and a
**T target still loses the name the way *T used to.

Allocations are unchanged by the guard and still below the original:

    &i                     6353 B/op   24 allocs/op
    i + nil guard          6331 B/op   22 allocs/op
@aryanmehrotra

Copy link
Copy Markdown
Member Author

Thorough behaviour check — and a correction to my previous comment

I said the only side effect of dropping the & was a typed nil pointer starting to error. That was understated. Differencing every target shape rather than the handful I had looked at showed a nil pointer used to swallow five body shapes, not one. Pushed 24d75c08 to preserve all of them.

Method

10 target shapes × 12 body shapes = 120 cases, run through three implementations, comparing GoFr on Go 1.26 (today's behaviour) against each candidate on Go 1.27. Targets: *struct, nil *struct, *map, nil *map, *slice, *[]byte, *any, *int, *string, **struct. Bodies: valid, wrong-type-string, wrong-type-int, nested-wrong-type, malformed, empty, null, unknown-field, array-into-struct, bare number, bare string, invalid UTF-8.

candidate on Go 1.27 rows differing from Go 1.26 baseline
upgrade alone (&i, i.e. this PR before my commits) 7 / 120
upgrade + pointer fix (c041fe2a alone) 10 / 120
upgrade + pointer fix + nil guard (24d75c08) 5 / 120

My first fix made things worse on this measure before the guard was added — worth stating plainly.

What the nil guard restores

A nil pointer cannot be written to. The old form decoded into the local interface, which dropped the static type, so a nil pointer silently accepted anything:

body, into a nil *T Go 1.26 (baseline) pointer fix alone with nil guard
wrong-type-string nil, no-op ERR field T.a nil, no-op
wrong-type-int nil, no-op ERR field T.b nil, no-op
nested-wrong-type nil, no-op ERR field T.i.z nil, no-op
array-into-struct nil, no-op ERR cannot unmarshal array nil, no-op
bare number / string nil, no-op ERR cannot unmarshal … nil, no-op
malformed / empty syntax error syntax error syntax error

Reproduced exactly — decoded into a throwaway interface, so syntax errors still surface and type errors still do not. reflect.Value is hoisted out of the guard that was already there, so the check costs nothing.

To be clear about what this preserves: binding a nil pointer quietly succeeding is a defect of the same family GoFr fixed for non-pointers in #3770. I have kept it because the Go upgrade is not the place to change it. It deserves its own PR.

The 5 remaining differences are upstream, not reachable from GoFr

case Go 1.26 Go 1.27
*[]byte + invalid base64 illegal base64 data at input byte 0 json: cannot unmarshal string into Go value of type []uint8: illegal base64 data…
*struct nested wrong type field Inner.i.z field T.i.z (names the outer struct now)
**struct wrong type ×2, nested ×1 field T.a field .a — a **T target still loses the name

None of these are affected by how Bind calls Unmarshal; they are encoding/json's own rendering under json/v2.

Also changed by the upgrade, outside Bind

json.Marshal emits U+FFFD for invalid UTF-8 as a raw rune instead of , and GoFr has two encoders on that path:

  • pkg/gofr/http/responder.go:93json.NewEncoder(buf).Encode(resp), so response bodies change
  • pkg/gofr/logging/logger.go:107json.NewEncoder(out).Encode(entry), so log lines change

That is what the two wire-format test updates in this PR are pinning. It is not avoidable in a library — the default follows the application's own go directive — but it is a visible change for anyone whose payloads carry invalid UTF-8, and it belongs in the release notes for whichever release ships Go 1.27.

Performance

Allocation counts are deterministic; ns/op on this machine was too noisy to quote.

B/op allocs/op
&i (before) 6353 24
i + nil guard (after) 6331 22

The guard adds no allocation over the plain pointer form.

Linter

Agreed on keeping the golangci-lint v2.12.2 → v2.13.2 bump here — it is required, not optional. A v2.12.2 binary refuses the module outright:

can't load config: the Go language version (go1.26) used to build golangci-lint
is lower than the targeted Go version (1.27.0)

I hit that locally, which is why lint for these packages was verified in CI rather than on my machine.

Still open

The version matrix labels 1.25 / 1.26 / 1.27 but go.work plus .github/actions/setup-go-toolchain upgrades every leg to 1.27, so all three legs test the same toolchain.

Umang01-hash
Umang01-hash previously approved these changes Aug 31, 2026

@Umang01-hash Umang01-hash left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ran the touched packages on the real 1.27 toolchain — all green, gofmt/vet clean, and CI Code Quality is passing.

The Bind change is the nice bit: confirmed that under v2 the old Unmarshal(body, &i) drops the struct name from type errors (field .a vs field charBindTarget.a), and passing i directly fixes it. Also checked the nil-pointer path stays a no-op exactly like before, and that the v1 API still keeps case-insensitive matching + last-wins duplicate keys on 1.27, so nothing downstream shifts. LGTM.

The branch bumped every go directive to 1.27.0 while the CI matrix still
listed 1.25 and 1.26. Those two legs could not have run: a module whose
go.work says 1.27.0 is refused outright by an older toolchain.

    go: go.work requires go >= 1.27.0 (running go 1.26.0; GOTOOLCHAIN=go1.26.0)

They passed because .github/actions/setup-go-toolchain installed whatever
go.work asked for and set GOTOOLCHAIN=auto, so all three legs upgraded to
1.27 and tested the same toolchain while reporting three. The matrix has
been decorative for a while — every release since v1.56.0 carried a go
directive newer than its own lowest leg.

Two things follow from the directive rather than the toolchain, which is
what makes this worth getting right:

  - encoding/json's json/v2 behaviour is selected by the main module's go
    line. At 1.27 invalid UTF-8 marshals as a raw U+FFFD instead of the
    escaped form, changing the bytes of every response body and log line,
    and UnmarshalTypeError renders struct fields differently.
  - the directive is a hard floor for users. At 1.27.0 nobody on 1.25 or
    1.26 can build GoFr at all.

So the directive stays at the minimum GoFr supports, 1.25.0, and the
matrix genuinely runs 1.25, 1.26 and 1.27 with GOTOOLCHAIN=local. GoFr
gains 1.27 coverage, behaves identically on all three, and forces nobody
to upgrade.

  - every go.mod and go.work: 1.25.0 (32 modules)
  - matrix: ['1.27','1.26','1.25'], GOTOOLCHAIN=local per leg
  - setup-go-toolchain removed; with the directive at the floor there is
    no upgrade to allow, and the covdata problem it worked around came
    from the toolchain-module GOROOT that the upgrade produced
  - lint jobs and the root Dockerfile build on 1.27, which is fine above
    the floor
  - README (claimed 1.24), docs/AGENTS.md (claimed 1.25) and the CI
    recipe now all say 1.25, matching go.mod for the first time
  - golangci-lint v2.12.2 -> v2.13.2 kept: v2.12.2 refuses a module it
    considers newer than itself

The three test changes for json/v2 wire formats are reverted, since at
1.25 the old bytes and error strings are what the stdlib produces. Only
the Bind pointer fix is kept — differencing 10 target shapes against 12
bodies shows 0 of 120 cases behave differently from the previous form
under 1.25, and it drops two allocations per bind.
…an run

The previous commit set the floor to 1.25 so three legs could run for
real. CI answered that 1.25 is not reachable, twice:

  go: github.com/jlaffaye/ftp@v0.2.1 requires go >= 1.26
      (running go 1.25.14; GOTOOLCHAIN=local)

  go: gofr.dev/pkg/gofr/datasource/file/s3@v0.3.0 requires go >= 1.26.0
      (running go 1.25.14; GOTOOLCHAIN=go1.25.14)

The first was ours to fix: examples/using-add-filestore held
jlaffaye/ftp v0.2.1, which declares go 1.26, while
pkg/gofr/datasource/file/ftp already used v0.2.2, which declares go
1.20. Aligned on v0.2.2.

The second is not fixable here. Our own published submodules declare go
1.26.0, and the examples depend on them by version, so a 1.25 toolchain
cannot resolve the workspace no matter what this branch declares. That
would need those submodules re-released against a lower directive.

So the floor is 1.26 and the matrix is 1.26 and 1.27, both real:

  - every go.mod and go.work: 1.26.0, unchanged from v1.60.0, so nobody
    is forced to upgrade
  - matrix: ['1.27','1.26'] with GOTOOLCHAIN=local, verified against a
    genuine go1.26.0 rather than assumed — go mod download resolves, the
    tree builds, and the http, logging and middleware suites pass
  - coverage upload moves to the 1.26 leg, still the lowest
  - README, docs/AGENTS.md and the CI recipe say 1.26, matching go.mod

Dropping the third leg is not a reduction in coverage. It never ran: the
toolchain shim upgraded it, so it re-tested the newest version under an
older label. Two legs that run beat three that do not.

Behaviour is identical on both. json/v2 is selected by the go directive,
which is the same on every leg, so the wire formats and error strings
that this branch originally rewrote stay as they are on development.
A Template returned from a DELETE handler gets status 204, which must not
carry a body. The responder rendered into it anyway and threw the result
away:

    r.w.WriteHeader(statusCode)   // 204
    v.Render(r.w)                 // Render discards Execute's error

net/http drops a body on a 204, so no client has ever received those
bytes -- verified against a real server on 1.25, 1.26 and 1.27, all three
answering 204 with an empty body and no Content-Length. The defect was
only ever visible to something that records writes verbatim, and there it
was version-dependent, because httptest.ResponseRecorder keeps the bytes
while reporting the error:

    write to a recorder after WriteHeader(204)
      go1.25.14   n=4  err=<nil>
      go1.26.0    n=0  err=http: request method or response status code does not allow body

html/template writes incrementally and stops at the first write error, so
what the recorder held changed with the toolchain:

    go1.25.14   <h1>Hi</h1>     the whole page
    go1.26.0    <h1>            truncated at the first text node
    go1.27.0    <h1>

The test pinned "<h1>" and called the truncation a latent bug, which held
only because every leg of the CI matrix silently ran the newest Go. A
genuine 1.25 leg fails on it.

The render is skipped when the status forbids a body, using the same rule
net/http applies. Nothing on the wire changes: an empty body before, an
empty body after, on every version. What changes is that GoFr no longer
parses and executes a template whose output it cannot send, and the
recorder now sees what the client sees.

Scoped to Template deliberately. File and XML write their body in one
call and ignore the error, so a recorder keeps it identically on every
version -- the same wasted write, but no divergence. Worth tidying, not
worth folding into a Go upgrade.

Verified on genuine toolchains, not on one pretending to be three: the
whole ./pkg/... suite passes on go1.25.14 with this fix, and
TestResponder_Char passes on 1.25, 1.26 and 1.27 alike.
The root module is what people import, and it depends on no published
gofr.dev release, so it can be built and tested on 1.25. What could not
was the workspace: examples and datasource submodules require published
gofr.dev v1.57.0/v1.59.0 and file/s3 v0.3.0, and those declare go 1.26.0,
so resolving go.work needs 1.26 whatever this branch says.

Testing the root module outside the workspace separates the two. The PKG
job sets GOWORK=off and runs 1.25, 1.26 and 1.27; the Example and
Submodule jobs keep 1.26 and 1.27, since they genuinely need a published
release that requires it.

  - root go.mod: 1.25.0. Submodules and examples stay 1.26.0, which is
    what their own dependencies force.
  - PKG matrix ['1.27','1.26','1.25'], GOWORK=off, GOTOOLCHAIN=local
  - PKG coverage upload follows its own lowest leg, now 1.25
  - README, docs/AGENTS.md and the CI recipe give both floors: 1.25 for
    the framework, 1.26 for the datasource modules

Verified against real toolchains rather than a matrix label: go1.25.14,
go1.26.0 and go1.27.0 each build the root module, the full ./pkg/... suite
passes on go1.25.14, and the workspace still resolves on go1.26.0 for the
jobs that use it.

This is the first time the older legs run anything. Every release since
v1.56.0 shipped a go directive above its own lowest leg, so the toolchain
upgraded underneath and the leg re-tested the newest version under an
older name.
Correcting an error of mine. I had concluded the json/v2 wire format was
selected by the main module's go directive, and on that basis reverted
these two expectations to the pre-1.27 escape and asserted the legs would
agree. The 1.27 leg failed, and a full matrix shows the directive has
nothing to do with it -- the toolchain decides:

    go.mod directive     toolchain 1.26        toolchain 1.27
    1.25.0               {"uri":"/�/ok"}  {"uri":"/<U+FFFD>/ok"}
    1.26.0               {"uri":"/�/ok"}  {"uri":"/<U+FFFD>/ok"}
    1.27.0               refuses to build      {"uri":"/<U+FFFD>/ok"}

So no single literal can pass on all three legs, which is why the first
revision of this branch changed these tests and my revert changed them
back: each was right for the toolchain it was tested against and wrong
for the others.

The expectation now moves with the toolchain, through a constant selected
by a go1.27 build tag. Both files carry the reasoning so the next person
does not have to rediscover it.

This is a real user-visible difference, not a test artifact. GoFr encodes
its logs and its response envelope with encoding/json, so a service built
with Go 1.27 emits different bytes for invalid UTF-8 than the same
service built with 1.26. Nothing in GoFr can prevent that, but it belongs
in the release notes of whichever release first tests against 1.27.

Verified on genuine toolchains: pkg/gofr/logging and
pkg/gofr/http/middleware pass on go1.25.14, go1.26.0 and go1.27.0, and
the whole ./pkg/... suite passes on all three. The one failure seen along
the way, TestCircuitBreaker_SlowHealthCheckDoesNotBlock on 1.26, is the
known timing flake and passes at -count=3 on both 1.26 and 1.27.
@aryanmehrotra

Copy link
Copy Markdown
Member Author

Closing this without merging. The branch chore/go-1.27 stays, so nothing here is lost and this can be reopened.

What the work turned up, for whoever picks it up next:

The version matrix was never testing three versions. go.work declared a newer Go than the two older legs, and .github/actions/setup-go-toolchain upgraded them to satisfy it, so all three legs ran the newest toolchain under three names. Every release since v1.56.0 shipped a go directive above its own lowest leg, so this predates the branch.

Making the legs real is possible, but only for the root module. gofr.dev depends on no published GoFr release, so it builds and tests on 1.25 with GOWORK=off. The workspace cannot: examples and datasource submodules require published gofr.dev@v1.57.0/v1.59.0 and file/s3@v0.3.0, and those declare go 1.26.0 permanently. Nothing third-party blocks 1.25 — the constraint is entirely GoFr's own published artifacts.

Two real defects surfaced once the older legs actually ran, both fixed here and both worth keeping regardless of what happens to the Go upgrade:

  • Bind unmarshalled through a pointer to an interface, which on Go 1.27 drops the struct name from the error (field .a rather than field charBindTarget.a). Differenced across 10 target shapes and 12 bodies; 0 of 120 cases change behaviour, and it removes two allocations per bind.
  • A Template returned from a DELETE handler renders into a 204, which cannot carry a body. Render discards Execute's error, so the attempt left a truncated page in anything recording writes verbatim — and where it truncated moved with the toolchain. A real client has always received an empty body on every version.

One difference cannot be fixed in GoFr and should be release-noted. The json/v2 wire format is selected by the toolchain, not by the go directive — a build on 1.27 emits invalid UTF-8 as a raw U+FFFD where 1.26 emits . GoFr encodes its logs and its response envelope with encoding/json, so the same service emits different bytes depending on the Go it was built with. The tests here pin it per toolchain behind a go1.27 build tag.

Worth splitting out of a version bump if this is revisited: the two fixes stand alone, and the matrix correction is independent of which Go version is being added.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants