Skip to content
Merged
61 changes: 61 additions & 0 deletions example/uploadreleaseassetfromrelease/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright 2025 The go-github AUTHORS. All rights reserved.
//
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// The uploadreleaseassetfromrelease example demonstrates how to upload
// a release asset using the UploadReleaseAssetFromRelease helper.
package main

import (
"bytes"
"context"
"fmt"
"log"
"os"

"github.com/google/go-github/v80/github"
)

func main() {
token := os.Getenv("GITHUB_AUTH_TOKEN")
if token == "" {
log.Fatal("GITHUB_AUTH_TOKEN not set")
}

ctx := context.Background()
client := github.NewClient(nil).WithAuthToken(token)

owner := "OWNER"
repo := "REPO"
releaseID := int64(1)

// Fetch the release (UploadURL is populated by the API)
release, _, err := client.Repositories.GetRelease(ctx, owner, repo, releaseID)
if err != nil {
log.Fatalf("GetRelease failed: %v", err)
}

// Asset content
data := []byte("Hello from go-github!\n")
reader := bytes.NewReader(data)
size := int64(len(data))

opts := &github.UploadOptions{
Name: "example.txt",
Label: "Example asset",
}

asset, _, err := client.Repositories.UploadReleaseAssetFromRelease(
ctx,
release,
opts,
reader,
size,
)
if err != nil {
log.Fatalf("UploadReleaseAssetFromRelease failed: %v", err)
}

fmt.Printf("Uploaded asset ID: %v\n", asset.GetID())
}
73 changes: 73 additions & 0 deletions github/repos_releases.go
Original file line number Diff line number Diff line change
Expand Up @@ -488,3 +488,76 @@ func (s *RepositoriesService) UploadReleaseAsset(ctx context.Context, owner, rep
}
return asset, resp, nil
}

// UploadReleaseAssetFromRelease uploads an asset using the UploadURL that's embedded
// in a RepositoryRelease object.
//
// This is a convenience wrapper that extracts the release.UploadURL (which is usually
// templated like "https://uploads.github.com/.../assets{?name,label}") and uploads
// the provided data (reader + size) using the existing upload helpers.
Comment on lines +492 to +497
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe we can add a new example that demonstrates how to use this wrapper?

//
// GitHub API docs: https://docs.github.com/rest/releases/assets#upload-a-release-asset
//
//meta:operation POST /repos/{owner}/{repo}/releases/{release_id}/assets
func (s *RepositoriesService) UploadReleaseAssetFromRelease(
ctx context.Context,
release *RepositoryRelease,
opts *UploadOptions,
reader io.Reader,
size int64,
) (*ReleaseAsset, *Response, error) {
if release == nil || release.UploadURL == nil {
return nil, nil, errors.New("release UploadURL must be provided")
}
if reader == nil {
return nil, nil, errors.New("reader must be provided")
}
if size < 0 {
return nil, nil, errors.New("size must be >= 0")
}

// Strip URI-template portion (e.g. "{?name,label}") if present.
uploadURL := *release.UploadURL
if idx := strings.Index(uploadURL, "{"); idx != -1 {
uploadURL = uploadURL[:idx]
}

// If this is a *relative* URL (no scheme), normalize it by trimming a leading "/"
// so it works with Client.BaseURL path prefixes (e.g. "/api-v3/").
if !strings.HasPrefix(uploadURL, "http://") && !strings.HasPrefix(uploadURL, "https://") {
uploadURL = strings.TrimPrefix(uploadURL, "/")
}

// addOptions will append name/label query params (same behavior as UploadReleaseAsset).
u, err := addOptions(uploadURL, opts)
if err != nil {
return nil, nil, err
}

// determine media type
mediaType := defaultMediaType
if opts != nil {
switch {
case opts.MediaType != "":
mediaType = opts.MediaType
case opts.Name != "":
if ext := filepath.Ext(opts.Name); ext != "" {
if mt := mime.TypeByExtension(ext); mt != "" {
mediaType = mt
}
}
}
}

req, err := s.client.NewUploadRequest(u, reader, size, mediaType)
if err != nil {
return nil, nil, err
}

asset := new(ReleaseAsset)
resp, err := s.client.Do(ctx, req, asset)
if err != nil {
return nil, resp, err
}
return asset, resp, nil
}
203 changes: 203 additions & 0 deletions github/repos_releases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -930,3 +930,206 @@ func TestGenerateNotesOptions_Marshal(t *testing.T) {

testJSONMarshal(t, u, want)
}

func TestRepositoriesService_UploadReleaseAssetFromRelease(t *testing.T) {
t.Parallel()

var (
defaultUploadOptions = &UploadOptions{Name: "n.txt"}
defaultExpectedFormValue = values{"name": "n.txt"}
mediaTypeTextPlain = "text/plain; charset=utf-8"
)

client, mux, _ := setup(t)

// Use the same endpoint path used in other release asset tests.
mux.HandleFunc("/repos/o/r/releases/1/assets", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "POST")
testHeader(t, r, "Content-Type", mediaTypeTextPlain)
testHeader(t, r, "Content-Length", "12")
testFormValues(t, r, defaultExpectedFormValue)
testBody(t, r, "Upload me !\n")

fmt.Fprint(w, `{"id":1}`)
})

body := []byte("Upload me !\n")
reader := bytes.NewReader(body)
size := int64(len(body))

// Provide a templated upload URL like GitHub returns.
uploadURL := "/repos/o/r/releases/1/assets{?name,label}"
release := &RepositoryRelease{
UploadURL: &uploadURL,
}

ctx := t.Context()
asset, _, err := client.Repositories.UploadReleaseAssetFromRelease(ctx, release, defaultUploadOptions, reader, size)
if err != nil {
t.Fatalf("Repositories.UploadReleaseAssetFromRelease returned error: %v", err)
}
want := &ReleaseAsset{ID: Ptr(int64(1))}
if !cmp.Equal(asset, want) {
t.Fatalf("Repositories.UploadReleaseAssetFromRelease returned %+v, want %+v", asset, want)
}
}

func TestRepositoriesService_UploadReleaseAssetFromRelease_AbsoluteTemplate(t *testing.T) {
t.Parallel()
client, mux, _ := setup(t)

mux.HandleFunc("/repos/o/r/releases/1/assets", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "POST")
// Expect name query param created by addOptions after trimming template.
if got := r.URL.Query().Get("name"); got != "abs.txt" {
t.Errorf("Expected name query param 'abs.txt', got %q", got)
}
fmt.Fprint(w, `{"id":1}`)
})

body := []byte("Upload me !\n")
reader := bytes.NewReader(body)
size := int64(len(body))

// Build an absolute URL using the test client's BaseURL.
absoluteUploadURL := client.BaseURL.String() + "repos/o/r/releases/1/assets{?name,label}"
release := &RepositoryRelease{UploadURL: &absoluteUploadURL}

opts := &UploadOptions{Name: "abs.txt"}
ctx := t.Context()
asset, _, err := client.Repositories.UploadReleaseAssetFromRelease(ctx, release, opts, reader, size)
if err != nil {
t.Fatalf("UploadReleaseAssetFromRelease returned error: %v", err)
}
want := &ReleaseAsset{ID: Ptr(int64(1))}
if !cmp.Equal(asset, want) {
t.Fatalf("UploadReleaseAssetFromRelease returned %+v, want %+v", asset, want)
}
}

func TestRepositoriesService_UploadReleaseAssetFromRelease_NilRelease(t *testing.T) {
t.Parallel()
client, _, _ := setup(t)

body := []byte("Upload me !\n")
reader := bytes.NewReader(body)
size := int64(len(body))

ctx := t.Context()
_, _, err := client.Repositories.UploadReleaseAssetFromRelease(ctx, nil, &UploadOptions{Name: "n.txt"}, reader, size)
if err == nil {
t.Fatal("expected error for nil release, got nil")
}

const methodName = "UploadReleaseAssetFromRelease"
testBadOptions(t, methodName, func() (err error) {
_, _, err = client.Repositories.UploadReleaseAssetFromRelease(ctx, nil, &UploadOptions{Name: "n.txt"}, reader, size)
return err
})
}

func TestRepositoriesService_UploadReleaseAssetFromRelease_NilReader(t *testing.T) {
t.Parallel()
client, _, _ := setup(t)

uploadURL := "/repos/o/r/releases/1/assets{?name,label}"
release := &RepositoryRelease{UploadURL: &uploadURL}

ctx := t.Context()
_, _, err := client.Repositories.UploadReleaseAssetFromRelease(ctx, release, &UploadOptions{Name: "n.txt"}, nil, 12)
if err == nil {
t.Fatal("expected error when reader is nil")
}

const methodName = "UploadReleaseAssetFromRelease"
testBadOptions(t, methodName, func() (err error) {
_, _, err = client.Repositories.UploadReleaseAssetFromRelease(ctx, release, &UploadOptions{Name: "n.txt"}, nil, 12)
return err
})
}

func TestRepositoriesService_UploadReleaseAssetFromRelease_NegativeSize(t *testing.T) {
t.Parallel()
client, _, _ := setup(t)

uploadURL := "/repos/o/r/releases/1/assets{?name,label}"
release := &RepositoryRelease{UploadURL: &uploadURL}

body := []byte("Upload me !\n")
reader := bytes.NewReader(body)

ctx := t.Context()
_, _, err := client.Repositories.UploadReleaseAssetFromRelease(ctx, release, &UploadOptions{Name: "n..txt"}, reader, -1)
if err == nil {
t.Fatal("expected error when size is negative")
}
}

func TestRepositoriesService_UploadReleaseAssetFromRelease_NoOpts(t *testing.T) {
t.Parallel()
client, mux, _ := setup(t)

// No opts: we just assert that the handler is hit and body is as expected.
mux.HandleFunc("/repos/o/r/releases/1/assets", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "POST")
testBody(t, r, "Upload me !\n")
fmt.Fprint(w, `{"id":1}`)
})

body := []byte("Upload me !\n")
reader := bytes.NewReader(body)
size := int64(len(body))

uploadURL := "/repos/o/r/releases/1/assets{?name,label}"
release := &RepositoryRelease{UploadURL: &uploadURL}

ctx := t.Context()
asset, _, err := client.Repositories.UploadReleaseAssetFromRelease(ctx, release, nil, reader, size)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := &ReleaseAsset{ID: Ptr(int64(1))}
if !cmp.Equal(asset, want) {
t.Fatalf("Repositories.UploadReleaseAssetFromRelease returned %+v, want %+v", asset, want)
}

const methodName = "UploadReleaseAssetFromRelease"
testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) {
got, resp, err := client.Repositories.UploadReleaseAssetFromRelease(ctx, release, nil, reader, size)
if got != nil {
t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got)
}
return resp, err
})
}

func TestRepositoriesService_UploadReleaseAssetFromRelease_WithMediaType(t *testing.T) {
t.Parallel()
client, mux, _ := setup(t)

// Expect explicit media type to be used.
mux.HandleFunc("/repos/o/r/releases/1/assets", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "POST")
testHeader(t, r, "Content-Type", "image/png")
fmt.Fprint(w, `{"id":1}`)
})

body := []byte("Binary!")
reader := bytes.NewReader(body)
size := int64(len(body))

uploadURL := "/repos/o/r/releases/1/assets{?name,label}"
release := &RepositoryRelease{UploadURL: &uploadURL}

opts := &UploadOptions{Name: "n.txt", MediaType: "image/png"}

ctx := t.Context()
asset, _, err := client.Repositories.UploadReleaseAssetFromRelease(ctx, release, opts, reader, size)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := &ReleaseAsset{ID: Ptr(int64(1))}
if !cmp.Equal(asset, want) {
t.Fatalf("UploadReleaseAssetFromRelease returned %+v, want %+v", asset, want)
}
}
Loading