Skip to content
Merged
69 changes: 69 additions & 0 deletions github/repos_releases.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"io"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -488,3 +489,71 @@ 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 file using the existing upload helpers.
//
// 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, file *os.File) (*ReleaseAsset, *Response, error) {
Comment thread
rockygeekz marked this conversation as resolved.
Outdated
Copy link
Copy Markdown
Collaborator

@gmlewis gmlewis Dec 4, 2025

Choose a reason for hiding this comment

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

Using a file *os.File here is unnecessarily restrictive.
Note that the method that this function calls (NewUploadRequest) takes:

func (c *Client) NewUploadRequest(urlStr string, reader io.Reader, size int64, mediaType string, opts ...RequestOption) (*http.Request, error) {

which allows uploading of data from a much more flexible range of sources.

Please adopt the same API in this helper method.

if release == nil || release.UploadURL == nil {
return nil, nil, errors.New("release UploadURL must be provided")
}
if file == nil {
return nil, nil, errors.New("file must be provided")
}

// Extract upload URL.
uploadURL := *release.UploadURL

// If uploadURL contains a template, strip it (e.g. "{?name,label}").
if idx := strings.Index(uploadURL, "{"); idx != -1 {
uploadURL = uploadURL[:idx]
}

// If uploadURL is absolute (starts with http/https), parse and use only the path.
if strings.HasPrefix(uploadURL, "http://") || strings.HasPrefix(uploadURL, "https://") {
if uParsed, err := url.Parse(uploadURL); err == nil {
uploadURL = uParsed.Path
}
}

// Defensive: always remove any leading '/' so client gets a relative path.
uploadURL = strings.TrimPrefix(uploadURL, "/")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I was not expecting the conversion of the upload URL to a relative URL - that would make the entire point of using the upload URL from the release needlessly depend on the client upload API URL which might have been set with Client.WithEnterpriseURLs().

My intention for the feature request was also to remove the need to set the upload API endpoint manually - since the GitHub core API is easily discoverable in GitHub Actions context, but the upload API is not directly discoverable (which it doesn't need to be, since it is defined by the Release the upload is for).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I was not expecting the conversion of the upload URL to a relative URL - that would make the entire point of using the upload URL from the release needlessly depend on the client upload API URL which might have been set with Client.WithEnterpriseURLs().

My intention for the feature request was also to remove the need to set the upload API endpoint manually - since the GitHub core API is easily discoverable in GitHub Actions context, but the upload API is not directly discoverable (which it doesn't need to be, since it is defined by the Release the upload is for).

@klaernie - can you please give a hypothetical example of the flow that you are envisioning so that we have a more clear picture of the issue you are trying to solve? Or maybe just walk through the steps you have in mind using pseudo-code as to how you picture this new helper method to work?

Copy link
Copy Markdown

@klaernie klaernie Dec 5, 2025

Choose a reason for hiding this comment

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

Essential the story is quite a short one:

I needed to build a project using goreleaser locally (forking to an internal GitHub Enterprise instance). Since the project in question is built with GitHub Actions and the goreleaser action specifically I only needed to supply the right GHA variables for publishing the resulting docker image, so the image ends up in the correct registry.

However for creating a release and uploading the binaries to the release it was not that easy.

For this I first need to find out the API endpoints of the API on the internal GHES, once for the v3 API and once for the upload API. Both URLs then need to be set in the .goreleaser.yaml in the forked project to enable goreleaser to publish the release and upload the binaries. This than means, that in order to contribute changes made in our GHES back to the upstream project we must always strip the commits changing the goreleaser configuration, so we do not break upstreams build process or leak internal information.

However since the workflow is running in GitHub Actions the v3 API endpoint for the GHES is provided in the workflow's github context, so goreleaser and/or goreleaser-action have theoretically all information needed in order to work without explicit configuration.

But for the Upload API this case is different:

  • the API endpoint is not officially disclosed in the documentation, and only listed in the examples
  • the API endpoint is however returned almost fully formed by the v3 API when obtaining a release (either by fetching or creating it)

To me this means that we (as a library implementing the API) were never supposed to hardcode the Upload API endpoint, but always should have extracted the URL for uploading from the release. And we are sure to definitely need the information about the release anyway, since uploading does not work without knowledge of the GitHub internal release ID - which one can only obtain by fetching a release by name or iterating all releases.

To put this in pseudocode this flow is always as such:

release, resp, err := client.Repositories.CreateRelease(ctx, owner, repo, &RepositoryRelease... })
for asset := range assets {
	... := client.Repositories.UploadReleaseAssetFromRelease(ctx, release, &UploadOptions{Name: asset.name, Label: asset.label}, asset.content)
}

I hope this makes the use case a lot clearer, I definitely fell into the trap of assuming too much of my knowledge to be implicitly known instead of making it explicit.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@rockygeekz - do you have enough information to proceed, or do you have questions about this? (Please make sure to read the edited version above and not the email to be truly up-to-date.)

I, myself, have not dug deeply into the issue yet, but if you two can figure it out, then wonderful... please proceed.
If not, please let me know.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the detailed explanation - this makes the intended behavior fully clear.

I now understand the core requirement:

  • If the release provides an absolute upload_url, the client should use it as-is, including its host.
  • This avoids depending on Client.WithEnterpriseURLs() and ensures uploading works correctly on GHES, where the upload API endpoint can’t be inferred from the main API endpoint.
  • Only the URI-template portion ({?name,label}) should be handled, not by converting the URL to a relative path, but by making it usable for the upload.

Based on this, I’ll update the implementation so that:

  1. Absolute URLs are preserved completely (no conversion to a relative URL).
  2. Only the template section is handled/trimmed, with no changes to host or path.
  3. The helper will take a flexible input (io.Reader + size) to match NewUploadRequest.
  4. Tests will be updated to reflect this behavior.

If you’d like to move to a proper URI-template implementation later, I’m happy to follow up in another PR - for now I’ll align with the clarified behavior above.

Working on the updates now. Thanks again for the clarification!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Update

Thanks for the feedback! I've updated the implementation based on what you all suggested.

What changed:

  • Switched the helper to accept io.Reader + size (matching NewUploadRequest)
  • Strip only the {?name,label} URI-template portion
  • Preserve absolute upload URLs exactly as the API returns them
  • No path rewriting or conversion to relative URLs
  • Added full test coverage for:
    • absolute URLs
    • template stripping
    • nil release / nil reader
    • negative size
    • no opts
    • media type override
    • bad options
    • testNewRequestAndDoFailure branches

Result

This now works as intended for the GHES use-case — the upload endpoint comes directly from the release object without needing Client.WithEnterpriseURLs().

Next steps

If you want any refinements (like using a URI-template library), just let me know and I'll update it.

Thanks again!


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

stat, err := file.Stat()
if err != nil {
return nil, nil, err
}
if stat.IsDir() {
return nil, nil, errors.New("the asset to upload can't be a directory")
}

mediaType := mime.TypeByExtension(filepath.Ext(file.Name()))
if opts != nil && opts.MediaType != "" {
mediaType = opts.MediaType
}

req, err := s.client.NewUploadRequest(u, file, stat.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
}
77 changes: 77 additions & 0 deletions github/repos_releases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,83 @@ func TestRepositoriesService_UploadReleaseAsset(t *testing.T) {
}
}

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

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

client, mux, _ := setup(t)

// Use the same endpoint path used in other 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}`)
})

// Create a file using the test helper that existing tests use.
file := openTestFile(t, "upload.txt", "Upload me !\n")

// 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, file)
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)
}

const methodName = "UploadReleaseAssetFromRelease"
testBadOptions(t, methodName, func() (err error) {
_, _, err = client.Repositories.UploadReleaseAssetFromRelease(ctx, release, defaultUploadOptions, nil)
return err
})
}

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 r.URL.Query().Get("name") != "abs.txt" {
t.Errorf("Expected name query param 'abs.txt', got %q", r.URL.Query().Get("name"))
}
fmt.Fprint(w, `{"id":1}`)
})

file := openTestFile(t, "upload.txt", "Upload me !\n")

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

opts := &UploadOptions{Name: "abs.txt"}
asset, _, err := client.Repositories.UploadReleaseAssetFromRelease(t.Context(), release, opts, file)
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 TestRepositoryReleaseRequest_Marshal(t *testing.T) {
t.Parallel()
testJSONMarshal(t, &repositoryReleaseRequest{}, "{}")
Expand Down
Loading