From 3268d1b288c96e217a49e45a304606d1bbefd551 Mon Sep 17 00:00:00 2001 From: Steve Hipwell Date: Thu, 21 May 2026 15:37:49 +0100 Subject: [PATCH 1/5] feat: add client api version support Signed-off-by: Steve Hipwell --- github/github.go | 53 ++++++++++++++++- github/github_test.go | 112 +++++++++++++++++++++++++++++++++++ github/private_registries.go | 48 +++++++++++++-- 3 files changed, 204 insertions(+), 9 deletions(-) diff --git a/github/github.go b/github/github.go index c4da5391195..3de9535f3cd 100644 --- a/github/github.go +++ b/github/github.go @@ -161,6 +161,9 @@ const ( mediaTypeContentAttachmentsPreview = "application/vnd.github.corsair-preview+json" ) +// apiVersionRegexp is a regular expression to validate API version strings. +var apiVersionRegexp = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) + // ErrPathForbidden is returned when a URL path contains ".." as a path // segment, which could allow path traversal attacks. var ErrPathForbidden = errors.New("path must not contain '..' due to auth vulnerability issue") @@ -178,6 +181,9 @@ type Client struct { // Base URL for uploading files. uploadURL *url.URL + // API version to set in the X-Github-Api-Version header. + apiVersion string + // User agent used when communicating with the GitHub API. userAgent string @@ -347,6 +353,7 @@ type clientOptions struct { httpClient *http.Client transport http.RoundTripper timeout *time.Duration + apiVersion *string userAgent *string envProxy bool token *string @@ -405,6 +412,27 @@ func WithTimeout(timeout time.Duration) ClientOptionsFunc { } } +// WithAPIVersion returns a ClientOptionsFunc that sets the API version for a +// Client. The API version should be in the format "YYYY-MM-DD" as specified by +// GitHub's API versioning scheme. If not set, the default API version will be +// used. +// Warning: Setting the API version to anything other than [defaultAPIVersion] +// is not recommended and may cause compatibility issues with this package. +func WithAPIVersion(apiVersion string) ClientOptionsFunc { + return func(o *clientOptions) error { + if apiVersion == "" { + return errors.New("api version must not be empty") + } + + if !apiVersionRegexp.MatchString(apiVersion) { + return errors.New("invalid api version") + } + + o.apiVersion = &apiVersion + return nil + } +} + // WithUserAgent returns a ClientOptionsFunc that sets the User-Agent header // for a Client. If not set, a default User-Agent will be used. func WithUserAgent(userAgent string) ClientOptionsFunc { @@ -609,6 +637,12 @@ func newClient(opts clientOptions) (*Client, error) { CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } + if opts.apiVersion != nil { + c.apiVersion = *opts.apiVersion + } else { + c.apiVersion = defaultAPIVersion + } + if opts.userAgent != nil { c.userAgent = *opts.userAgent } else { @@ -684,6 +718,18 @@ func newClient(opts clientOptions) (*Client, error) { return c, nil } +// APIVersion returns the API version set in the X-Github-Api-Version header +// for the client. +func (c *Client) APIVersion() string { + return c.apiVersion +} + +// CheckAPIVersion checks if the client's API version is compatible with the +// provided minimum version. +func (c *Client) CheckAPIVersion(minVersion string) bool { + return minVersion <= c.apiVersion +} + // UserAgent returns the User-Agent header value for the client. func (c *Client) UserAgent() string { return c.userAgent @@ -718,6 +764,7 @@ func (c *Client) Clone(opts ...ClientOptionsFunc) (*Client, error) { } o := clientOptions{ + apiVersion: &c.apiVersion, userAgent: &c.userAgent, baseURL: Ptr(*c.baseURL), uploadURL: Ptr(*c.uploadURL), @@ -814,7 +861,7 @@ func (c *Client) NewRequest(ctx context.Context, method, urlStr string, body any if c.userAgent != "" { req.Header.Set("User-Agent", c.userAgent) } - req.Header.Set(headerAPIVersion, defaultAPIVersion) + req.Header.Set(headerAPIVersion, c.apiVersion) for _, opt := range opts { opt(req) @@ -851,7 +898,7 @@ func (c *Client) NewFormRequest(ctx context.Context, urlStr string, body io.Read if c.userAgent != "" { req.Header.Set("User-Agent", c.userAgent) } - req.Header.Set(headerAPIVersion, defaultAPIVersion) + req.Header.Set(headerAPIVersion, c.apiVersion) for _, opt := range opts { opt(req) @@ -922,7 +969,7 @@ func (c *Client) NewUploadRequest(ctx context.Context, urlStr string, reader io. req.Header.Set("Content-Type", mediaType) req.Header.Set("Accept", mediaTypeV3) req.Header.Set("User-Agent", c.userAgent) - req.Header.Set(headerAPIVersion, defaultAPIVersion) + req.Header.Set(headerAPIVersion, c.apiVersion) for _, opt := range opts { opt(req) diff --git a/github/github_test.go b/github/github_test.go index 2beedbddef3..36381e33988 100644 --- a/github/github_test.go +++ b/github/github_test.go @@ -560,6 +560,57 @@ func TestWithTimeout(t *testing.T) { }) } +func TestWithAPIVersion(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + version string + wantVersion string + wantErr string + }{ + { + name: "empty_version", + version: "", + wantErr: "api version must not be empty", + }, + { + name: "invalid_version", + version: "1.0.0", + wantErr: "invalid api version", + }, + { + name: "valid_version", + version: defaultAPIVersion, + wantVersion: defaultAPIVersion, + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + opts := clientOptions{} + err := WithAPIVersion(tt.version)(&opts) + if err != nil { + if tt.wantErr == "" { + t.Fatalf("unexpected error: %v", err) + } + if err.Error() != tt.wantErr { + t.Fatalf("want error %v, got %v", tt.wantErr, err) + } + return + } + + if tt.wantErr != "" { + t.Fatalf("want error %v, got nil", tt.wantErr) + } + + if opts.apiVersion != nil && *opts.apiVersion != tt.wantVersion { + t.Errorf("want apiVersion %v, got %v", tt.wantVersion, *opts.apiVersion) + } + }) + } +} + func TestWithUserAgent(t *testing.T) { t.Parallel() @@ -1010,6 +1061,7 @@ func Test_newClient(t *testing.T) { httpClient: &http.Client{Transport: &http.Transport{IdleConnTimeout: 5 * time.Second}}, transport: &http.Transport{IdleConnTimeout: 10 * time.Second}, timeout: Ptr(15 * time.Second), + apiVersion: Ptr(latestAPIVersion), userAgent: Ptr("CustomUserAgent/1.0"), baseURL: mustParseURL(t, "https://custom-url/api/v3/"), uploadURL: mustParseURL(t, "https://custom-upload-url/api/uploads/"), @@ -1092,6 +1144,13 @@ func Test_newClient(t *testing.T) { t.Error("newClient http.Client used for redirects should have a CheckRedirect function") } + if tt.opts.apiVersion != nil && c.apiVersion != *tt.opts.apiVersion { + t.Errorf("newClient apiVersion is %v, want %v", c.apiVersion, *tt.opts.apiVersion) + } + if tt.opts.apiVersion == nil && c.apiVersion != defaultAPIVersion { + t.Errorf("newClient apiVersion is %v, want %v", c.apiVersion, defaultAPIVersion) + } + if tt.opts.userAgent != nil && c.userAgent != *tt.opts.userAgent { t.Errorf("newClient userAgent is %v, want %v", c.userAgent, *tt.opts.userAgent) } @@ -1141,6 +1200,58 @@ func Test_newClient(t *testing.T) { } } +func TestClient_APIVersion(t *testing.T) { + t.Parallel() + + c := mustNewClient(t) + c.apiVersion = defaultAPIVersion + + if got, want := c.APIVersion(), defaultAPIVersion; got != want { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestClient_CheckAPIVersion(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + apiVersion string + minVersion string + want bool + }{ + { + name: "version_equal_to_min", + apiVersion: defaultAPIVersion, + minVersion: defaultAPIVersion, + want: true, + }, + { + name: "version_greater_than_min", + apiVersion: latestAPIVersion, + minVersion: defaultAPIVersion, + want: true, + }, + { + name: "version_less_than_min", + apiVersion: defaultAPIVersion, + minVersion: latestAPIVersion, + want: false, + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + c := mustNewClient(t) + c.apiVersion = tt.apiVersion + + if got := c.CheckAPIVersion(tt.minVersion); got != tt.want { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} + func TestClient_UserAgent(t *testing.T) { t.Parallel() @@ -1274,6 +1385,7 @@ func TestClient_Clone(t *testing.T) { t.Parallel() c := mustNewClient(t) + c.apiVersion = latestAPIVersion c.userAgent = "CustomUserAgent/1.0" c.baseURL.Path = "/custom/" c.uploadURL.Path = "/custom-upload/" diff --git a/github/private_registries.go b/github/private_registries.go index d1899f4f9a8..b02153f2eff 100644 --- a/github/private_registries.go +++ b/github/private_registries.go @@ -244,7 +244,13 @@ func (s *PrivateRegistriesService) ListOrganizationPrivateRegistries(ctx context return nil, nil, err } - req, err := s.client.NewRequest(ctx, "GET", u, nil, WithVersion(api20260310)) + minVersion := api20260310 + reqOpts := []RequestOption{} + if !s.client.CheckAPIVersion(minVersion) { + reqOpts = append(reqOpts, WithVersion(minVersion)) + } + + req, err := s.client.NewRequest(ctx, "GET", u, nil, reqOpts...) if err != nil { return nil, nil, err } @@ -265,7 +271,13 @@ func (s *PrivateRegistriesService) ListOrganizationPrivateRegistries(ctx context func (s *PrivateRegistriesService) CreateOrganizationPrivateRegistry(ctx context.Context, org string, privateRegistry CreateOrganizationPrivateRegistry) (*PrivateRegistry, *Response, error) { u := fmt.Sprintf("orgs/%v/private-registries", org) - req, err := s.client.NewRequest(ctx, "POST", u, privateRegistry, WithVersion(api20260310)) + minVersion := api20260310 + reqOpts := []RequestOption{} + if !s.client.CheckAPIVersion(minVersion) { + reqOpts = append(reqOpts, WithVersion(minVersion)) + } + + req, err := s.client.NewRequest(ctx, "POST", u, privateRegistry, reqOpts...) if err != nil { return nil, nil, err } @@ -286,7 +298,13 @@ func (s *PrivateRegistriesService) CreateOrganizationPrivateRegistry(ctx context func (s *PrivateRegistriesService) GetOrganizationPrivateRegistriesPublicKey(ctx context.Context, org string) (*PublicKey, *Response, error) { u := fmt.Sprintf("orgs/%v/private-registries/public-key", org) - req, err := s.client.NewRequest(ctx, "GET", u, nil, WithVersion(api20260310)) + minVersion := api20260310 + reqOpts := []RequestOption{} + if !s.client.CheckAPIVersion(minVersion) { + reqOpts = append(reqOpts, WithVersion(minVersion)) + } + + req, err := s.client.NewRequest(ctx, "GET", u, nil, reqOpts...) if err != nil { return nil, nil, err } @@ -308,7 +326,13 @@ func (s *PrivateRegistriesService) GetOrganizationPrivateRegistriesPublicKey(ctx func (s *PrivateRegistriesService) GetOrganizationPrivateRegistry(ctx context.Context, org, secretName string) (*PrivateRegistry, *Response, error) { u := fmt.Sprintf("orgs/%v/private-registries/%v", org, secretName) - req, err := s.client.NewRequest(ctx, "GET", u, nil, WithVersion(api20260310)) + minVersion := api20260310 + reqOpts := []RequestOption{} + if !s.client.CheckAPIVersion(minVersion) { + reqOpts = append(reqOpts, WithVersion(minVersion)) + } + + req, err := s.client.NewRequest(ctx, "GET", u, nil, reqOpts...) if err != nil { return nil, nil, err } @@ -331,7 +355,13 @@ func (s *PrivateRegistriesService) GetOrganizationPrivateRegistry(ctx context.Co func (s *PrivateRegistriesService) UpdateOrganizationPrivateRegistry(ctx context.Context, org, secretName string, privateRegistry UpdateOrganizationPrivateRegistry) (*Response, error) { u := fmt.Sprintf("orgs/%v/private-registries/%v", org, secretName) - req, err := s.client.NewRequest(ctx, "PATCH", u, privateRegistry, WithVersion(api20260310)) + minVersion := api20260310 + reqOpts := []RequestOption{} + if !s.client.CheckAPIVersion(minVersion) { + reqOpts = append(reqOpts, WithVersion(minVersion)) + } + + req, err := s.client.NewRequest(ctx, "PATCH", u, privateRegistry, reqOpts...) if err != nil { return nil, err } @@ -348,7 +378,13 @@ func (s *PrivateRegistriesService) UpdateOrganizationPrivateRegistry(ctx context func (s *PrivateRegistriesService) DeleteOrganizationPrivateRegistry(ctx context.Context, org, secretName string) (*Response, error) { u := fmt.Sprintf("orgs/%v/private-registries/%v", org, secretName) - req, err := s.client.NewRequest(ctx, "DELETE", u, nil, WithVersion(api20260310)) + minVersion := api20260310 + reqOpts := []RequestOption{} + if !s.client.CheckAPIVersion(minVersion) { + reqOpts = append(reqOpts, WithVersion(minVersion)) + } + + req, err := s.client.NewRequest(ctx, "DELETE", u, nil, reqOpts...) if err != nil { return nil, err } From b9a8a7b0c3ef46f1ee7ee37b3ba60f9b9043959f Mon Sep 17 00:00:00 2001 From: Steve Hipwell Date: Fri, 22 May 2026 10:59:04 +0100 Subject: [PATCH 2/5] fixup! feat: add client api version support --- github/github.go | 90 ++++++-------- github/github_test.go | 230 ++++++++++++++++++----------------- github/private_registries.go | 48 +------- 3 files changed, 163 insertions(+), 205 deletions(-) diff --git a/github/github.go b/github/github.go index 3de9535f3cd..4b75f8c29b6 100644 --- a/github/github.go +++ b/github/github.go @@ -40,10 +40,8 @@ const ( HeaderRequestID = "X-Github-Request-Id" // https://docs.github.com/en/rest/about-the-rest-api/api-versions#about-api-versioning - defaultAPIVersion = api20221128 - latestAPIVersion = api20260310 - api20221128 = "2022-11-28" - api20260310 = "2026-03-10" + api20221128 = "2022-11-28" + api20260310 = "2026-03-10" defaultBaseURL = "https://api.github.com/" defaultUserAgent = "go-github" + "/" + Version @@ -181,8 +179,12 @@ type Client struct { // Base URL for uploading files. uploadURL *url.URL - // API version to set in the X-Github-Api-Version header. - apiVersion string + // Default API version to set in the X-Github-Api-Version header. + apiVersionDefault string + // Minimum API version that the client can use. + apiVersionMin string + // Maximum API version that the client can use. + apiVersionMax string // User agent used when communicating with the GitHub API. userAgent string @@ -353,7 +355,8 @@ type clientOptions struct { httpClient *http.Client transport http.RoundTripper timeout *time.Duration - apiVersion *string + apiVersionMin *string + apiVersionMax *string userAgent *string envProxy bool token *string @@ -412,27 +415,6 @@ func WithTimeout(timeout time.Duration) ClientOptionsFunc { } } -// WithAPIVersion returns a ClientOptionsFunc that sets the API version for a -// Client. The API version should be in the format "YYYY-MM-DD" as specified by -// GitHub's API versioning scheme. If not set, the default API version will be -// used. -// Warning: Setting the API version to anything other than [defaultAPIVersion] -// is not recommended and may cause compatibility issues with this package. -func WithAPIVersion(apiVersion string) ClientOptionsFunc { - return func(o *clientOptions) error { - if apiVersion == "" { - return errors.New("api version must not be empty") - } - - if !apiVersionRegexp.MatchString(apiVersion) { - return errors.New("invalid api version") - } - - o.apiVersion = &apiVersion - return nil - } -} - // WithUserAgent returns a ClientOptionsFunc that sets the User-Agent header // for a Client. If not set, a default User-Agent will be used. func WithUserAgent(userAgent string) ClientOptionsFunc { @@ -586,7 +568,11 @@ func NewClient(opts ...ClientOptionsFunc) (*Client, error) { // newClient creates a new Client with the provided options. This is an internal // helper function that is called by [NewClient] and [Client.Clone]. func newClient(opts clientOptions) (*Client, error) { - c := &Client{} + c := &Client{ + apiVersionDefault: api20221128, + apiVersionMin: api20221128, + apiVersionMax: api20260310, + } if opts.httpClient != nil { c.client = opts.httpClient @@ -637,12 +623,6 @@ func newClient(opts clientOptions) (*Client, error) { CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } - if opts.apiVersion != nil { - c.apiVersion = *opts.apiVersion - } else { - c.apiVersion = defaultAPIVersion - } - if opts.userAgent != nil { c.userAgent = *opts.userAgent } else { @@ -718,18 +698,6 @@ func newClient(opts clientOptions) (*Client, error) { return c, nil } -// APIVersion returns the API version set in the X-Github-Api-Version header -// for the client. -func (c *Client) APIVersion() string { - return c.apiVersion -} - -// CheckAPIVersion checks if the client's API version is compatible with the -// provided minimum version. -func (c *Client) CheckAPIVersion(minVersion string) bool { - return minVersion <= c.apiVersion -} - // UserAgent returns the User-Agent header value for the client. func (c *Client) UserAgent() string { return c.userAgent @@ -764,7 +732,8 @@ func (c *Client) Clone(opts ...ClientOptionsFunc) (*Client, error) { } o := clientOptions{ - apiVersion: &c.apiVersion, + apiVersionMin: &c.apiVersionMin, + apiVersionMax: &c.apiVersionMax, userAgent: &c.userAgent, baseURL: Ptr(*c.baseURL), uploadURL: Ptr(*c.uploadURL), @@ -861,7 +830,7 @@ func (c *Client) NewRequest(ctx context.Context, method, urlStr string, body any if c.userAgent != "" { req.Header.Set("User-Agent", c.userAgent) } - req.Header.Set(headerAPIVersion, c.apiVersion) + req.Header.Set(headerAPIVersion, c.apiVersionDefault) for _, opt := range opts { opt(req) @@ -898,7 +867,7 @@ func (c *Client) NewFormRequest(ctx context.Context, urlStr string, body io.Read if c.userAgent != "" { req.Header.Set("User-Agent", c.userAgent) } - req.Header.Set(headerAPIVersion, c.apiVersion) + req.Header.Set(headerAPIVersion, c.apiVersionDefault) for _, opt := range opts { opt(req) @@ -969,7 +938,7 @@ func (c *Client) NewUploadRequest(ctx context.Context, urlStr string, reader io. req.Header.Set("Content-Type", mediaType) req.Header.Set("Accept", mediaTypeV3) req.Header.Set("User-Agent", c.userAgent) - req.Header.Set(headerAPIVersion, c.apiVersion) + req.Header.Set(headerAPIVersion, c.apiVersionDefault) for _, opt := range opts { opt(req) @@ -1190,6 +1159,21 @@ const ( // unexpectedly large error body. const maxErrorBodySize = 1 * 1024 * 1024 // 1 MiB +// ErrUnsupportedAPIVersion is returned when the API version specified in the +// request is not supported by the client. +var ErrUnsupportedAPIVersion = errors.New("unsupported api version") + +// checkRequestAPIVersionBeforeDo checks if the API version specified in the +// request is supported by the client before making the API call. If the +// version is not supported, it returns [ErrUnsupportedAPIVersion]. +func (c *Client) checkRequestAPIVersionBeforeDo(req *http.Request) error { + reqAPIVersion := req.Header.Get(headerAPIVersion) + if reqAPIVersion < c.apiVersionMin || reqAPIVersion > c.apiVersionMax { + return ErrUnsupportedAPIVersion + } + return nil +} + // bareDo sends an API request using `caller` http.Client passed in the parameters // and lets you handle the api response. If an error or API Error occurs, the error // will contain more information. Otherwise, you are supposed to read and close the @@ -1198,6 +1182,10 @@ const maxErrorBodySize = 1 * 1024 * 1024 // 1 MiB func (c *Client) bareDo(caller *http.Client, req *http.Request) (*Response, error) { ctx := req.Context() + if err := c.checkRequestAPIVersionBeforeDo(req); err != nil { + return nil, err + } + rateLimitCategory := CoreCategory if !c.disableRateLimitCheck { diff --git a/github/github_test.go b/github/github_test.go index 36381e33988..1e63d93d3ad 100644 --- a/github/github_test.go +++ b/github/github_test.go @@ -560,57 +560,6 @@ func TestWithTimeout(t *testing.T) { }) } -func TestWithAPIVersion(t *testing.T) { - t.Parallel() - - for _, tt := range []struct { - name string - version string - wantVersion string - wantErr string - }{ - { - name: "empty_version", - version: "", - wantErr: "api version must not be empty", - }, - { - name: "invalid_version", - version: "1.0.0", - wantErr: "invalid api version", - }, - { - name: "valid_version", - version: defaultAPIVersion, - wantVersion: defaultAPIVersion, - }, - } { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - opts := clientOptions{} - err := WithAPIVersion(tt.version)(&opts) - if err != nil { - if tt.wantErr == "" { - t.Fatalf("unexpected error: %v", err) - } - if err.Error() != tt.wantErr { - t.Fatalf("want error %v, got %v", tt.wantErr, err) - } - return - } - - if tt.wantErr != "" { - t.Fatalf("want error %v, got nil", tt.wantErr) - } - - if opts.apiVersion != nil && *opts.apiVersion != tt.wantVersion { - t.Errorf("want apiVersion %v, got %v", tt.wantVersion, *opts.apiVersion) - } - }) - } -} - func TestWithUserAgent(t *testing.T) { t.Parallel() @@ -1061,7 +1010,8 @@ func Test_newClient(t *testing.T) { httpClient: &http.Client{Transport: &http.Transport{IdleConnTimeout: 5 * time.Second}}, transport: &http.Transport{IdleConnTimeout: 10 * time.Second}, timeout: Ptr(15 * time.Second), - apiVersion: Ptr(latestAPIVersion), + apiVersionMin: Ptr(api20221128), + apiVersionMax: Ptr(api20221128), userAgent: Ptr("CustomUserAgent/1.0"), baseURL: mustParseURL(t, "https://custom-url/api/v3/"), uploadURL: mustParseURL(t, "https://custom-upload-url/api/uploads/"), @@ -1144,11 +1094,18 @@ func Test_newClient(t *testing.T) { t.Error("newClient http.Client used for redirects should have a CheckRedirect function") } - if tt.opts.apiVersion != nil && c.apiVersion != *tt.opts.apiVersion { - t.Errorf("newClient apiVersion is %v, want %v", c.apiVersion, *tt.opts.apiVersion) + if tt.opts.apiVersionMin != nil && c.apiVersionMin != *tt.opts.apiVersionMin { + t.Errorf("newClient apiVersionMin is %v, want %v", c.apiVersionMin, *tt.opts.apiVersionMin) + } + if tt.opts.apiVersionMin == nil && c.apiVersionMin != api20221128 { + t.Errorf("newClient apiVersionMin is %v, want %v", c.apiVersionMin, api20221128) + } + + if tt.opts.apiVersionMax != nil && c.apiVersionMax != *tt.opts.apiVersionMax { + t.Errorf("newClient apiVersionMax is %v, want %v", c.apiVersionMax, *tt.opts.apiVersionMax) } - if tt.opts.apiVersion == nil && c.apiVersion != defaultAPIVersion { - t.Errorf("newClient apiVersion is %v, want %v", c.apiVersion, defaultAPIVersion) + if tt.opts.apiVersionMax == nil && c.apiVersionMax != api20260310 { + t.Errorf("newClient apiVersionMax is %v, want %v", c.apiVersionMax, api20260310) } if tt.opts.userAgent != nil && c.userAgent != *tt.opts.userAgent { @@ -1200,58 +1157,6 @@ func Test_newClient(t *testing.T) { } } -func TestClient_APIVersion(t *testing.T) { - t.Parallel() - - c := mustNewClient(t) - c.apiVersion = defaultAPIVersion - - if got, want := c.APIVersion(), defaultAPIVersion; got != want { - t.Errorf("got %v, want %v", got, want) - } -} - -func TestClient_CheckAPIVersion(t *testing.T) { - t.Parallel() - - for _, tt := range []struct { - name string - apiVersion string - minVersion string - want bool - }{ - { - name: "version_equal_to_min", - apiVersion: defaultAPIVersion, - minVersion: defaultAPIVersion, - want: true, - }, - { - name: "version_greater_than_min", - apiVersion: latestAPIVersion, - minVersion: defaultAPIVersion, - want: true, - }, - { - name: "version_less_than_min", - apiVersion: defaultAPIVersion, - minVersion: latestAPIVersion, - want: false, - }, - } { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - c := mustNewClient(t) - c.apiVersion = tt.apiVersion - - if got := c.CheckAPIVersion(tt.minVersion); got != tt.want { - t.Errorf("got %v, want %v", got, tt.want) - } - }) - } -} - func TestClient_UserAgent(t *testing.T) { t.Parallel() @@ -1385,7 +1290,8 @@ func TestClient_Clone(t *testing.T) { t.Parallel() c := mustNewClient(t) - c.apiVersion = latestAPIVersion + c.apiVersionMin = api20221128 + c.apiVersionMax = api20221128 c.userAgent = "CustomUserAgent/1.0" c.baseURL.Path = "/custom/" c.uploadURL.Path = "/custom-upload/" @@ -1693,7 +1599,7 @@ func TestNewRequest(t *testing.T) { } apiVersion := req.Header.Get(headerAPIVersion) - if got, want := apiVersion, defaultAPIVersion; got != want { + if got, want := apiVersion, api20221128; got != want { t.Errorf("NewRequest() %v header is %v, want %v", headerAPIVersion, got, want) } @@ -1900,7 +1806,7 @@ func TestNewFormRequest(t *testing.T) { } apiVersion := req.Header.Get(headerAPIVersion) - if got, want := apiVersion, defaultAPIVersion; got != want { + if got, want := apiVersion, api20221128; got != want { t.Errorf("NewFormRequest() %v header is %v, want %v", headerAPIVersion, got, want) } @@ -1976,7 +1882,7 @@ func TestNewUploadRequest_WithVersion(t *testing.T) { req, _ := c.NewUploadRequest(t.Context(), "https://example.com/", nil, 0, "") apiVersion := req.Header.Get(headerAPIVersion) - if got, want := apiVersion, defaultAPIVersion; got != want { + if got, want := apiVersion, api20221128; got != want { t.Errorf("NewRequest() %v header is %v, want %v", headerAPIVersion, got, want) } @@ -3238,6 +3144,106 @@ func TestDo_noContent(t *testing.T) { } } +func TestClient_checkRequestAPIVersionBeforeDo(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + version string + versionMin string + versionMax string + wantErr bool + }{ + { + name: "version_not_set", + version: "", + versionMin: api20221128, + versionMax: api20260310, + wantErr: true, + }, + { + name: "version_less_than_min", + version: "2022-01-01", + versionMin: api20221128, + versionMax: api20260310, + wantErr: true, + }, + { + name: "version_equal_to_min", + version: api20221128, + versionMin: api20221128, + versionMax: api20260310, + wantErr: false, + }, + { + name: "version_between_min_and_max", + version: "2023-01-01", + versionMin: api20221128, + versionMax: api20260310, + wantErr: false, + }, + { + name: "version_equal_to_max", + version: api20260310, + versionMin: api20221128, + versionMax: api20260310, + wantErr: false, + }, + { + name: "version_greater_than_max", + version: api20260310, + versionMin: api20221128, + versionMax: api20221128, + wantErr: true, + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + client := mustNewClient(t) + client.apiVersionMin = tt.versionMin + client.apiVersionMax = tt.versionMax + + req, _ := http.NewRequestWithContext(t.Context(), "GET", ".", nil) + req.Header.Set(headerAPIVersion, tt.version) + + err := client.checkRequestAPIVersionBeforeDo(req) + if tt.wantErr { + if err == nil { + t.Fatal("Expected error to be returned, got nil.") + } + if !errors.Is(err, ErrUnsupportedAPIVersion) { + t.Errorf("Expected ErrUnsupportedAPIVersion; got %#v.", err) + } + return + } + + if err != nil { + t.Fatalf("Expected no error to be returned, got: %v", err) + } + }) + } +} + +func TestClient_bareDo_errors_with_unsupported_api_version(t *testing.T) { + t.Parallel() + + c := mustNewClient(t) + c.apiVersionMin = api20221128 + c.apiVersionMax = api20221128 + + req, _ := http.NewRequestWithContext(t.Context(), "GET", ".", nil) + req.Header.Set(headerAPIVersion, api20260310) + + _, err := c.bareDo(c.client, req) + if err == nil { + t.Fatal("Expected error to be returned, got nil.") + } + if !errors.Is(err, ErrUnsupportedAPIVersion) { + t.Errorf("Expected ErrUnsupportedAPIVersion; got %#v.", err) + } +} + func TestBareDoUntilFound_redirectLoop(t *testing.T) { t.Parallel() client, mux, _ := setup(t) diff --git a/github/private_registries.go b/github/private_registries.go index b02153f2eff..d1899f4f9a8 100644 --- a/github/private_registries.go +++ b/github/private_registries.go @@ -244,13 +244,7 @@ func (s *PrivateRegistriesService) ListOrganizationPrivateRegistries(ctx context return nil, nil, err } - minVersion := api20260310 - reqOpts := []RequestOption{} - if !s.client.CheckAPIVersion(minVersion) { - reqOpts = append(reqOpts, WithVersion(minVersion)) - } - - req, err := s.client.NewRequest(ctx, "GET", u, nil, reqOpts...) + req, err := s.client.NewRequest(ctx, "GET", u, nil, WithVersion(api20260310)) if err != nil { return nil, nil, err } @@ -271,13 +265,7 @@ func (s *PrivateRegistriesService) ListOrganizationPrivateRegistries(ctx context func (s *PrivateRegistriesService) CreateOrganizationPrivateRegistry(ctx context.Context, org string, privateRegistry CreateOrganizationPrivateRegistry) (*PrivateRegistry, *Response, error) { u := fmt.Sprintf("orgs/%v/private-registries", org) - minVersion := api20260310 - reqOpts := []RequestOption{} - if !s.client.CheckAPIVersion(minVersion) { - reqOpts = append(reqOpts, WithVersion(minVersion)) - } - - req, err := s.client.NewRequest(ctx, "POST", u, privateRegistry, reqOpts...) + req, err := s.client.NewRequest(ctx, "POST", u, privateRegistry, WithVersion(api20260310)) if err != nil { return nil, nil, err } @@ -298,13 +286,7 @@ func (s *PrivateRegistriesService) CreateOrganizationPrivateRegistry(ctx context func (s *PrivateRegistriesService) GetOrganizationPrivateRegistriesPublicKey(ctx context.Context, org string) (*PublicKey, *Response, error) { u := fmt.Sprintf("orgs/%v/private-registries/public-key", org) - minVersion := api20260310 - reqOpts := []RequestOption{} - if !s.client.CheckAPIVersion(minVersion) { - reqOpts = append(reqOpts, WithVersion(minVersion)) - } - - req, err := s.client.NewRequest(ctx, "GET", u, nil, reqOpts...) + req, err := s.client.NewRequest(ctx, "GET", u, nil, WithVersion(api20260310)) if err != nil { return nil, nil, err } @@ -326,13 +308,7 @@ func (s *PrivateRegistriesService) GetOrganizationPrivateRegistriesPublicKey(ctx func (s *PrivateRegistriesService) GetOrganizationPrivateRegistry(ctx context.Context, org, secretName string) (*PrivateRegistry, *Response, error) { u := fmt.Sprintf("orgs/%v/private-registries/%v", org, secretName) - minVersion := api20260310 - reqOpts := []RequestOption{} - if !s.client.CheckAPIVersion(minVersion) { - reqOpts = append(reqOpts, WithVersion(minVersion)) - } - - req, err := s.client.NewRequest(ctx, "GET", u, nil, reqOpts...) + req, err := s.client.NewRequest(ctx, "GET", u, nil, WithVersion(api20260310)) if err != nil { return nil, nil, err } @@ -355,13 +331,7 @@ func (s *PrivateRegistriesService) GetOrganizationPrivateRegistry(ctx context.Co func (s *PrivateRegistriesService) UpdateOrganizationPrivateRegistry(ctx context.Context, org, secretName string, privateRegistry UpdateOrganizationPrivateRegistry) (*Response, error) { u := fmt.Sprintf("orgs/%v/private-registries/%v", org, secretName) - minVersion := api20260310 - reqOpts := []RequestOption{} - if !s.client.CheckAPIVersion(minVersion) { - reqOpts = append(reqOpts, WithVersion(minVersion)) - } - - req, err := s.client.NewRequest(ctx, "PATCH", u, privateRegistry, reqOpts...) + req, err := s.client.NewRequest(ctx, "PATCH", u, privateRegistry, WithVersion(api20260310)) if err != nil { return nil, err } @@ -378,13 +348,7 @@ func (s *PrivateRegistriesService) UpdateOrganizationPrivateRegistry(ctx context func (s *PrivateRegistriesService) DeleteOrganizationPrivateRegistry(ctx context.Context, org, secretName string) (*Response, error) { u := fmt.Sprintf("orgs/%v/private-registries/%v", org, secretName) - minVersion := api20260310 - reqOpts := []RequestOption{} - if !s.client.CheckAPIVersion(minVersion) { - reqOpts = append(reqOpts, WithVersion(minVersion)) - } - - req, err := s.client.NewRequest(ctx, "DELETE", u, nil, reqOpts...) + req, err := s.client.NewRequest(ctx, "DELETE", u, nil, WithVersion(api20260310)) if err != nil { return nil, err } From fda8d0037c3513ac7c877948b7bbd027b1625d57 Mon Sep 17 00:00:00 2001 From: Steve Hipwell Date: Fri, 22 May 2026 11:00:43 +0100 Subject: [PATCH 3/5] fixup! feat: add client api version support --- github/github.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/github/github.go b/github/github.go index 4b75f8c29b6..fd184dcc219 100644 --- a/github/github.go +++ b/github/github.go @@ -159,9 +159,6 @@ const ( mediaTypeContentAttachmentsPreview = "application/vnd.github.corsair-preview+json" ) -// apiVersionRegexp is a regular expression to validate API version strings. -var apiVersionRegexp = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) - // ErrPathForbidden is returned when a URL path contains ".." as a path // segment, which could allow path traversal attacks. var ErrPathForbidden = errors.New("path must not contain '..' due to auth vulnerability issue") From d7a0b8b12485bc79abb6ea25fca9828422e65f29 Mon Sep 17 00:00:00 2001 From: Steve Hipwell Date: Fri, 22 May 2026 14:18:42 +0100 Subject: [PATCH 4/5] fixup! feat: add client api version support --- github/github.go | 9 ++++++++- github/github_test.go | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/github/github.go b/github/github.go index fd184dcc219..9914edf9bd1 100644 --- a/github/github.go +++ b/github/github.go @@ -1162,12 +1162,19 @@ var ErrUnsupportedAPIVersion = errors.New("unsupported api version") // checkRequestAPIVersionBeforeDo checks if the API version specified in the // request is supported by the client before making the API call. If the -// version is not supported, it returns [ErrUnsupportedAPIVersion]. +// version is not supported, it returns [ErrUnsupportedAPIVersion]. If the +// version is empty it returns nil. func (c *Client) checkRequestAPIVersionBeforeDo(req *http.Request) error { reqAPIVersion := req.Header.Get(headerAPIVersion) + + if reqAPIVersion == "" { + return nil + } + if reqAPIVersion < c.apiVersionMin || reqAPIVersion > c.apiVersionMax { return ErrUnsupportedAPIVersion } + return nil } diff --git a/github/github_test.go b/github/github_test.go index 1e63d93d3ad..05a7720e268 100644 --- a/github/github_test.go +++ b/github/github_test.go @@ -3159,7 +3159,7 @@ func TestClient_checkRequestAPIVersionBeforeDo(t *testing.T) { version: "", versionMin: api20221128, versionMax: api20260310, - wantErr: true, + wantErr: false, }, { name: "version_less_than_min", From 63526854977c854ebbe1d1c9f1288ffa04358e07 Mon Sep 17 00:00:00 2001 From: Steve Hipwell Date: Fri, 22 May 2026 14:25:06 +0100 Subject: [PATCH 5/5] fixup! feat: add client api version support --- github/github.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/github/github.go b/github/github.go index 9914edf9bd1..2577d1def69 100644 --- a/github/github.go +++ b/github/github.go @@ -620,6 +620,14 @@ func newClient(opts clientOptions) (*Client, error) { CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } + if opts.apiVersionMin != nil { + c.apiVersionMin = *opts.apiVersionMin + } + + if opts.apiVersionMax != nil { + c.apiVersionMax = *opts.apiVersionMax + } + if opts.userAgent != nil { c.userAgent = *opts.userAgent } else {