From 07befdd786b3a48e42eb3d72c9e1ab965a3ac6a0 Mon Sep 17 00:00:00 2001 From: Markus Waldheim Date: Wed, 1 Jul 2026 19:25:30 +0200 Subject: [PATCH] fix(releases): set explicit Content-Length when uploading release assets Passing an *os.File directly as an http.Request body does not populate ContentLength (Go's http.NewRequest only special-cases in-memory readers like bytes.Buffer/bytes.Reader/strings.Reader). This caused requests to use chunked transfer encoding, which GitHub's real asset-upload endpoint rejects with 'Bad Content-Length' -- a failure mode the in-process httptest server did not surface, since it accepts chunked bodies fine. Fix by stat'ing the opened file and setting req.ContentLength explicitly. Added a regression test asserting the upload request carries a non-negative, correct Content-Length. Discovered via a real production run of semrel's own release pipeline, which uses this plugin directly (see SemRels/semrel#227, #229). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Markus Waldheim --- internal/plugin/releases.go | 9 +++++++++ internal/plugin/releases_test.go | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/internal/plugin/releases.go b/internal/plugin/releases.go index cf7d1f8..88f325a 100644 --- a/internal/plugin/releases.go +++ b/internal/plugin/releases.go @@ -241,6 +241,11 @@ func (c *client) uploadReleaseAsset(ctx context.Context, cfg Config, release *Re } defer func() { _ = file.Close() }() + stat, err := file.Stat() + if err != nil { + return fmt.Errorf("stat asset: %w", err) + } + contentType := mime.TypeByExtension(filepath.Ext(assetPath)) if contentType == "" { contentType = "application/octet-stream" @@ -250,6 +255,10 @@ func (c *client) uploadReleaseAsset(ctx context.Context, cfg Config, release *Re if err != nil { return fmt.Errorf("build upload request: %w", err) } + // GitHub's asset-upload endpoint requires an explicit Content-Length and + // rejects chunked transfer encoding. http.NewRequest does not infer the + // length for an *os.File body, so it must be set manually. + req.ContentLength = stat.Size() setGitHubHeaders(req, cfg.Token, contentType, baseURL) resp, err := c.httpClient.Do(req) diff --git a/internal/plugin/releases_test.go b/internal/plugin/releases_test.go index 4f06a01..06bf941 100644 --- a/internal/plugin/releases_test.go +++ b/internal/plugin/releases_test.go @@ -327,10 +327,19 @@ func TestUploadReleaseAssetsExpandsGlobs(t *testing.T) { if got := r.URL.Query().Get("name"); got == "" { t.Fatal("missing asset name query") } + // GitHub's real asset-upload endpoint requires an explicit + // Content-Length and rejects chunked transfer encoding, so guard + // against regressing to an *os.File body without a set length. + if r.ContentLength < 0 { + t.Fatalf("ContentLength = %d, want a non-negative explicit length (chunked transfer encoding not allowed)", r.ContentLength) + } body, err := io.ReadAll(r.Body) if err != nil { t.Fatalf("ReadAll() error = %v", err) } + if int64(len(body)) != r.ContentLength { + t.Fatalf("body length = %d, Content-Length header = %d", len(body), r.ContentLength) + } uploaded <- r.URL.Query().Get("name") + ":" + string(body) w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(`{"state":"uploaded"}`))