Skip to content

Commit 9188154

Browse files
authored
feat: Add UploadReleaseAssetFromRelease convenience helper (#3851)
1 parent e9632ee commit 9188154

3 files changed

Lines changed: 337 additions & 0 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright 2025 The go-github AUTHORS. All rights reserved.
2+
//
3+
// Use of this source code is governed by a BSD-style
4+
// license that can be found in the LICENSE file.
5+
6+
// The uploadreleaseassetfromrelease example demonstrates how to upload
7+
// a release asset using the UploadReleaseAssetFromRelease helper.
8+
package main
9+
10+
import (
11+
"bytes"
12+
"context"
13+
"fmt"
14+
"log"
15+
"os"
16+
17+
"github.com/google/go-github/v80/github"
18+
)
19+
20+
func main() {
21+
token := os.Getenv("GITHUB_AUTH_TOKEN")
22+
if token == "" {
23+
log.Fatal("GITHUB_AUTH_TOKEN not set")
24+
}
25+
26+
ctx := context.Background()
27+
client := github.NewClient(nil).WithAuthToken(token)
28+
29+
owner := "OWNER"
30+
repo := "REPO"
31+
releaseID := int64(1)
32+
33+
// Fetch the release (UploadURL is populated by the API)
34+
release, _, err := client.Repositories.GetRelease(ctx, owner, repo, releaseID)
35+
if err != nil {
36+
log.Fatalf("GetRelease failed: %v", err)
37+
}
38+
39+
// Asset content
40+
data := []byte("Hello from go-github!\n")
41+
reader := bytes.NewReader(data)
42+
size := int64(len(data))
43+
44+
opts := &github.UploadOptions{
45+
Name: "example.txt",
46+
Label: "Example asset",
47+
}
48+
49+
asset, _, err := client.Repositories.UploadReleaseAssetFromRelease(
50+
ctx,
51+
release,
52+
opts,
53+
reader,
54+
size,
55+
)
56+
if err != nil {
57+
log.Fatalf("UploadReleaseAssetFromRelease failed: %v", err)
58+
}
59+
60+
fmt.Printf("Uploaded asset ID: %v\n", asset.GetID())
61+
}

github/repos_releases.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,3 +488,76 @@ func (s *RepositoriesService) UploadReleaseAsset(ctx context.Context, owner, rep
488488
}
489489
return asset, resp, nil
490490
}
491+
492+
// UploadReleaseAssetFromRelease uploads an asset using the UploadURL that's embedded
493+
// in a RepositoryRelease object.
494+
//
495+
// This is a convenience wrapper that extracts the release.UploadURL (which is usually
496+
// templated like "https://uploads.github.com/.../assets{?name,label}") and uploads
497+
// the provided data (reader + size) using the existing upload helpers.
498+
//
499+
// GitHub API docs: https://docs.github.com/rest/releases/assets#upload-a-release-asset
500+
//
501+
//meta:operation POST /repos/{owner}/{repo}/releases/{release_id}/assets
502+
func (s *RepositoriesService) UploadReleaseAssetFromRelease(
503+
ctx context.Context,
504+
release *RepositoryRelease,
505+
opts *UploadOptions,
506+
reader io.Reader,
507+
size int64,
508+
) (*ReleaseAsset, *Response, error) {
509+
if release == nil || release.UploadURL == nil {
510+
return nil, nil, errors.New("release UploadURL must be provided")
511+
}
512+
if reader == nil {
513+
return nil, nil, errors.New("reader must be provided")
514+
}
515+
if size < 0 {
516+
return nil, nil, errors.New("size must be >= 0")
517+
}
518+
519+
// Strip URI-template portion (e.g. "{?name,label}") if present.
520+
uploadURL := *release.UploadURL
521+
if idx := strings.Index(uploadURL, "{"); idx != -1 {
522+
uploadURL = uploadURL[:idx]
523+
}
524+
525+
// If this is a *relative* URL (no scheme), normalize it by trimming a leading "/"
526+
// so it works with Client.BaseURL path prefixes (e.g. "/api-v3/").
527+
if !strings.HasPrefix(uploadURL, "http://") && !strings.HasPrefix(uploadURL, "https://") {
528+
uploadURL = strings.TrimPrefix(uploadURL, "/")
529+
}
530+
531+
// addOptions will append name/label query params (same behavior as UploadReleaseAsset).
532+
u, err := addOptions(uploadURL, opts)
533+
if err != nil {
534+
return nil, nil, err
535+
}
536+
537+
// determine media type
538+
mediaType := defaultMediaType
539+
if opts != nil {
540+
switch {
541+
case opts.MediaType != "":
542+
mediaType = opts.MediaType
543+
case opts.Name != "":
544+
if ext := filepath.Ext(opts.Name); ext != "" {
545+
if mt := mime.TypeByExtension(ext); mt != "" {
546+
mediaType = mt
547+
}
548+
}
549+
}
550+
}
551+
552+
req, err := s.client.NewUploadRequest(u, reader, size, mediaType)
553+
if err != nil {
554+
return nil, nil, err
555+
}
556+
557+
asset := new(ReleaseAsset)
558+
resp, err := s.client.Do(ctx, req, asset)
559+
if err != nil {
560+
return nil, resp, err
561+
}
562+
return asset, resp, nil
563+
}

github/repos_releases_test.go

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,3 +930,206 @@ func TestGenerateNotesOptions_Marshal(t *testing.T) {
930930

931931
testJSONMarshal(t, u, want)
932932
}
933+
934+
func TestRepositoriesService_UploadReleaseAssetFromRelease(t *testing.T) {
935+
t.Parallel()
936+
937+
var (
938+
defaultUploadOptions = &UploadOptions{Name: "n.txt"}
939+
defaultExpectedFormValue = values{"name": "n.txt"}
940+
mediaTypeTextPlain = "text/plain; charset=utf-8"
941+
)
942+
943+
client, mux, _ := setup(t)
944+
945+
// Use the same endpoint path used in other release asset tests.
946+
mux.HandleFunc("/repos/o/r/releases/1/assets", func(w http.ResponseWriter, r *http.Request) {
947+
testMethod(t, r, "POST")
948+
testHeader(t, r, "Content-Type", mediaTypeTextPlain)
949+
testHeader(t, r, "Content-Length", "12")
950+
testFormValues(t, r, defaultExpectedFormValue)
951+
testBody(t, r, "Upload me !\n")
952+
953+
fmt.Fprint(w, `{"id":1}`)
954+
})
955+
956+
body := []byte("Upload me !\n")
957+
reader := bytes.NewReader(body)
958+
size := int64(len(body))
959+
960+
// Provide a templated upload URL like GitHub returns.
961+
uploadURL := "/repos/o/r/releases/1/assets{?name,label}"
962+
release := &RepositoryRelease{
963+
UploadURL: &uploadURL,
964+
}
965+
966+
ctx := t.Context()
967+
asset, _, err := client.Repositories.UploadReleaseAssetFromRelease(ctx, release, defaultUploadOptions, reader, size)
968+
if err != nil {
969+
t.Fatalf("Repositories.UploadReleaseAssetFromRelease returned error: %v", err)
970+
}
971+
want := &ReleaseAsset{ID: Ptr(int64(1))}
972+
if !cmp.Equal(asset, want) {
973+
t.Fatalf("Repositories.UploadReleaseAssetFromRelease returned %+v, want %+v", asset, want)
974+
}
975+
}
976+
977+
func TestRepositoriesService_UploadReleaseAssetFromRelease_AbsoluteTemplate(t *testing.T) {
978+
t.Parallel()
979+
client, mux, _ := setup(t)
980+
981+
mux.HandleFunc("/repos/o/r/releases/1/assets", func(w http.ResponseWriter, r *http.Request) {
982+
testMethod(t, r, "POST")
983+
// Expect name query param created by addOptions after trimming template.
984+
if got := r.URL.Query().Get("name"); got != "abs.txt" {
985+
t.Errorf("Expected name query param 'abs.txt', got %q", got)
986+
}
987+
fmt.Fprint(w, `{"id":1}`)
988+
})
989+
990+
body := []byte("Upload me !\n")
991+
reader := bytes.NewReader(body)
992+
size := int64(len(body))
993+
994+
// Build an absolute URL using the test client's BaseURL.
995+
absoluteUploadURL := client.BaseURL.String() + "repos/o/r/releases/1/assets{?name,label}"
996+
release := &RepositoryRelease{UploadURL: &absoluteUploadURL}
997+
998+
opts := &UploadOptions{Name: "abs.txt"}
999+
ctx := t.Context()
1000+
asset, _, err := client.Repositories.UploadReleaseAssetFromRelease(ctx, release, opts, reader, size)
1001+
if err != nil {
1002+
t.Fatalf("UploadReleaseAssetFromRelease returned error: %v", err)
1003+
}
1004+
want := &ReleaseAsset{ID: Ptr(int64(1))}
1005+
if !cmp.Equal(asset, want) {
1006+
t.Fatalf("UploadReleaseAssetFromRelease returned %+v, want %+v", asset, want)
1007+
}
1008+
}
1009+
1010+
func TestRepositoriesService_UploadReleaseAssetFromRelease_NilRelease(t *testing.T) {
1011+
t.Parallel()
1012+
client, _, _ := setup(t)
1013+
1014+
body := []byte("Upload me !\n")
1015+
reader := bytes.NewReader(body)
1016+
size := int64(len(body))
1017+
1018+
ctx := t.Context()
1019+
_, _, err := client.Repositories.UploadReleaseAssetFromRelease(ctx, nil, &UploadOptions{Name: "n.txt"}, reader, size)
1020+
if err == nil {
1021+
t.Fatal("expected error for nil release, got nil")
1022+
}
1023+
1024+
const methodName = "UploadReleaseAssetFromRelease"
1025+
testBadOptions(t, methodName, func() (err error) {
1026+
_, _, err = client.Repositories.UploadReleaseAssetFromRelease(ctx, nil, &UploadOptions{Name: "n.txt"}, reader, size)
1027+
return err
1028+
})
1029+
}
1030+
1031+
func TestRepositoriesService_UploadReleaseAssetFromRelease_NilReader(t *testing.T) {
1032+
t.Parallel()
1033+
client, _, _ := setup(t)
1034+
1035+
uploadURL := "/repos/o/r/releases/1/assets{?name,label}"
1036+
release := &RepositoryRelease{UploadURL: &uploadURL}
1037+
1038+
ctx := t.Context()
1039+
_, _, err := client.Repositories.UploadReleaseAssetFromRelease(ctx, release, &UploadOptions{Name: "n.txt"}, nil, 12)
1040+
if err == nil {
1041+
t.Fatal("expected error when reader is nil")
1042+
}
1043+
1044+
const methodName = "UploadReleaseAssetFromRelease"
1045+
testBadOptions(t, methodName, func() (err error) {
1046+
_, _, err = client.Repositories.UploadReleaseAssetFromRelease(ctx, release, &UploadOptions{Name: "n.txt"}, nil, 12)
1047+
return err
1048+
})
1049+
}
1050+
1051+
func TestRepositoriesService_UploadReleaseAssetFromRelease_NegativeSize(t *testing.T) {
1052+
t.Parallel()
1053+
client, _, _ := setup(t)
1054+
1055+
uploadURL := "/repos/o/r/releases/1/assets{?name,label}"
1056+
release := &RepositoryRelease{UploadURL: &uploadURL}
1057+
1058+
body := []byte("Upload me !\n")
1059+
reader := bytes.NewReader(body)
1060+
1061+
ctx := t.Context()
1062+
_, _, err := client.Repositories.UploadReleaseAssetFromRelease(ctx, release, &UploadOptions{Name: "n..txt"}, reader, -1)
1063+
if err == nil {
1064+
t.Fatal("expected error when size is negative")
1065+
}
1066+
}
1067+
1068+
func TestRepositoriesService_UploadReleaseAssetFromRelease_NoOpts(t *testing.T) {
1069+
t.Parallel()
1070+
client, mux, _ := setup(t)
1071+
1072+
// No opts: we just assert that the handler is hit and body is as expected.
1073+
mux.HandleFunc("/repos/o/r/releases/1/assets", func(w http.ResponseWriter, r *http.Request) {
1074+
testMethod(t, r, "POST")
1075+
testBody(t, r, "Upload me !\n")
1076+
fmt.Fprint(w, `{"id":1}`)
1077+
})
1078+
1079+
body := []byte("Upload me !\n")
1080+
reader := bytes.NewReader(body)
1081+
size := int64(len(body))
1082+
1083+
uploadURL := "/repos/o/r/releases/1/assets{?name,label}"
1084+
release := &RepositoryRelease{UploadURL: &uploadURL}
1085+
1086+
ctx := t.Context()
1087+
asset, _, err := client.Repositories.UploadReleaseAssetFromRelease(ctx, release, nil, reader, size)
1088+
if err != nil {
1089+
t.Fatalf("unexpected error: %v", err)
1090+
}
1091+
want := &ReleaseAsset{ID: Ptr(int64(1))}
1092+
if !cmp.Equal(asset, want) {
1093+
t.Fatalf("Repositories.UploadReleaseAssetFromRelease returned %+v, want %+v", asset, want)
1094+
}
1095+
1096+
const methodName = "UploadReleaseAssetFromRelease"
1097+
testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) {
1098+
got, resp, err := client.Repositories.UploadReleaseAssetFromRelease(ctx, release, nil, reader, size)
1099+
if got != nil {
1100+
t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got)
1101+
}
1102+
return resp, err
1103+
})
1104+
}
1105+
1106+
func TestRepositoriesService_UploadReleaseAssetFromRelease_WithMediaType(t *testing.T) {
1107+
t.Parallel()
1108+
client, mux, _ := setup(t)
1109+
1110+
// Expect explicit media type to be used.
1111+
mux.HandleFunc("/repos/o/r/releases/1/assets", func(w http.ResponseWriter, r *http.Request) {
1112+
testMethod(t, r, "POST")
1113+
testHeader(t, r, "Content-Type", "image/png")
1114+
fmt.Fprint(w, `{"id":1}`)
1115+
})
1116+
1117+
body := []byte("Binary!")
1118+
reader := bytes.NewReader(body)
1119+
size := int64(len(body))
1120+
1121+
uploadURL := "/repos/o/r/releases/1/assets{?name,label}"
1122+
release := &RepositoryRelease{UploadURL: &uploadURL}
1123+
1124+
opts := &UploadOptions{Name: "n.txt", MediaType: "image/png"}
1125+
1126+
ctx := t.Context()
1127+
asset, _, err := client.Repositories.UploadReleaseAssetFromRelease(ctx, release, opts, reader, size)
1128+
if err != nil {
1129+
t.Fatalf("unexpected error: %v", err)
1130+
}
1131+
want := &ReleaseAsset{ID: Ptr(int64(1))}
1132+
if !cmp.Equal(asset, want) {
1133+
t.Fatalf("UploadReleaseAssetFromRelease returned %+v, want %+v", asset, want)
1134+
}
1135+
}

0 commit comments

Comments
 (0)