Merge pull request #10398 from iamazeem/6059-gh-release-create-only-new

[gh release create] Fail when there are no new commits since the last release
This commit is contained in:
William Martin 2025-02-19 12:13:55 +01:00 committed by GitHub
commit 1710f7dace
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 185 additions and 26 deletions

View file

@ -60,6 +60,7 @@ type CreateOptions struct {
NotesStartTag string
VerifyTag bool
NotesFromTag bool
FailOnNoCommits bool
}
func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
@ -99,6 +100,11 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
When using automatically generated release notes, a release title will also be automatically
generated unless a title was explicitly passed. Additional release notes can be prepended to
automatically generated notes by using the %[1]s--notes%[1]s flag.
By default, the release is created even if there are no new commits since the last release.
This may result in the same or duplicate release which may not be desirable in some cases.
Use %[1]s--fail-on-no-commits%[1]s to fail if no new commits are available. This flag has no
effect if there are no existing releases or this is the very first release.
`, "`"),
Example: heredoc.Doc(`
Interactively create a release
@ -130,6 +136,9 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
Create a release and start a discussion
$ gh release create v1.2.3 --discussion-category "General"
# Create a release only if there are new commits available since the last release
$ gh release create v1.2.3 --fail-on-no-commits
`),
Aliases: []string{"new"},
RunE: func(cmd *cobra.Command, args []string) error {
@ -194,6 +203,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
cmdutil.NilBoolFlag(cmd, &opts.IsLatest, "latest", "", "Mark this release as \"Latest\" (default [automatic based on date and version]). --latest=false to explicitly NOT set as latest")
cmd.Flags().BoolVarP(&opts.VerifyTag, "verify-tag", "", false, "Abort in case the git tag doesn't already exist in the remote repository")
cmd.Flags().BoolVarP(&opts.NotesFromTag, "notes-from-tag", "", false, "Automatically generate notes from annotated tag")
cmd.Flags().BoolVar(&opts.FailOnNoCommits, "fail-on-no-commits", false, "Fail if there are no commits since the last release (no impact on the first release)")
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "target")
@ -211,6 +221,16 @@ func createRun(opts *CreateOptions) error {
return err
}
if opts.FailOnNoCommits {
isNew, err := isNewRelease(httpClient, baseRepo)
if err != nil {
return fmt.Errorf("failed to check whether there were new commits since last release: %v", err)
}
if !isNew {
return fmt.Errorf("no new commits since the last release")
}
}
var existingTag bool
if opts.TagName == "" {
tags, err := getTags(httpClient, baseRepo, 5)

View file

@ -52,17 +52,18 @@ func Test_NewCmdCreate(t *testing.T) {
args: "",
isTTY: true,
want: CreateOptions{
TagName: "",
Target: "",
Name: "",
Body: "",
BodyProvided: false,
Draft: false,
Prerelease: false,
RepoOverride: "",
Concurrency: 5,
Assets: []*shared.AssetForUpload(nil),
VerifyTag: false,
TagName: "",
Target: "",
Name: "",
Body: "",
BodyProvided: false,
Draft: false,
Prerelease: false,
RepoOverride: "",
Concurrency: 5,
Assets: []*shared.AssetForUpload(nil),
VerifyTag: false,
FailOnNoCommits: false,
},
},
{
@ -76,17 +77,18 @@ func Test_NewCmdCreate(t *testing.T) {
args: "v1.2.3",
isTTY: true,
want: CreateOptions{
TagName: "v1.2.3",
Target: "",
Name: "",
Body: "",
BodyProvided: false,
Draft: false,
Prerelease: false,
RepoOverride: "",
Concurrency: 5,
Assets: []*shared.AssetForUpload(nil),
VerifyTag: false,
TagName: "v1.2.3",
Target: "",
Name: "",
Body: "",
BodyProvided: false,
Draft: false,
Prerelease: false,
RepoOverride: "",
Concurrency: 5,
Assets: []*shared.AssetForUpload(nil),
VerifyTag: false,
FailOnNoCommits: false,
},
},
{
@ -347,6 +349,19 @@ func Test_NewCmdCreate(t *testing.T) {
isTTY: false,
wantErr: "using `--notes-from-tag` with `--generate-notes` or `--notes-start-tag` is not supported",
},
{
name: "with --fail-on-no-commits",
args: "v1.2.3 --fail-on-no-commits",
isTTY: false,
want: CreateOptions{
TagName: "v1.2.3",
BodyProvided: false,
Concurrency: 5,
Assets: []*shared.AssetForUpload(nil),
NotesFromTag: false,
FailOnNoCommits: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -402,6 +417,7 @@ func Test_NewCmdCreate(t *testing.T) {
assert.Equal(t, tt.want.IsLatest, opts.IsLatest)
assert.Equal(t, tt.want.VerifyTag, opts.VerifyTag)
assert.Equal(t, tt.want.NotesFromTag, opts.NotesFromTag)
assert.Equal(t, tt.want.FailOnNoCommits, opts.FailOnNoCommits)
require.Equal(t, len(tt.want.Assets), len(opts.Assets))
for i := range tt.want.Assets {
@ -460,6 +476,100 @@ func Test_createRun(t *testing.T) {
wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n",
wantStderr: ``,
},
{
name: "create a release if there are new commits and the last release does not exist",
isTTY: true,
opts: CreateOptions{
TagName: "v1.2.3",
Name: "The Big 1.2",
Body: "* Fixed bugs",
BodyProvided: true,
Target: "",
FailOnNoCommits: true,
},
runStubs: defaultRunStubs,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "repos/OWNER/REPO/releases/latest"), httpmock.StatusStringResponse(404, `{
"message": "Not Found",
"documentation_url": "https://docs.github.com/rest/releases/releases#get-the-latest-release",
"status": "404"
}`))
reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases"), httpmock.RESTPayload(201, `{
"url": "https://api.github.com/releases/123",
"upload_url": "https://api.github.com/assets/upload",
"html_url": "https://github.com/OWNER/REPO/releases/tag/v1.2.3"
}`, func(params map[string]interface{}) {
assert.Equal(t, map[string]interface{}{
"tag_name": "v1.2.3",
"name": "The Big 1.2",
"body": "* Fixed bugs",
"draft": false,
"prerelease": false,
}, params)
}))
},
wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n",
wantStderr: ``,
},
{
name: "create a release if there are new commits and the last release exists",
isTTY: true,
opts: CreateOptions{
TagName: "v1.2.3",
Name: "The Big 1.2",
Body: "* Fixed bugs",
BodyProvided: true,
Target: "",
FailOnNoCommits: true,
},
runStubs: defaultRunStubs,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "repos/OWNER/REPO/releases/latest"), httpmock.StatusStringResponse(200, `{
"tag_name": "v1.2.2"
}`))
reg.Register(httpmock.REST("GET", "repos/OWNER/REPO/compare/v1.2.2...HEAD"), httpmock.StatusStringResponse(200, `{
"status": "ahead"
}`))
reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases"), httpmock.RESTPayload(201, `{
"url": "https://api.github.com/releases/123",
"upload_url": "https://api.github.com/assets/upload",
"html_url": "https://github.com/OWNER/REPO/releases/tag/v1.2.3"
}`, func(params map[string]interface{}) {
assert.Equal(t, map[string]interface{}{
"tag_name": "v1.2.3",
"name": "The Big 1.2",
"body": "* Fixed bugs",
"draft": false,
"prerelease": false,
}, params)
}))
},
wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n",
wantStderr: ``,
},
{
name: "create a release if there are no new commits but the last release exists",
isTTY: true,
opts: CreateOptions{
TagName: "v1.2.3",
Name: "The Big 1.2",
Body: "* Fixed bugs",
BodyProvided: true,
Target: "",
FailOnNoCommits: true,
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "repos/OWNER/REPO/releases/latest"), httpmock.StatusStringResponse(200, `{
"tag_name": "v1.2.2"
}`))
reg.Register(httpmock.REST("GET", "repos/OWNER/REPO/compare/v1.2.2...HEAD"), httpmock.StatusStringResponse(200, `{
"status": "identical"
}`))
},
wantErr: "no new commits since the last release",
wantStdout: "",
wantStderr: ``,
},
{
name: "with discussion category",
isTTY: true,

View file

@ -2,6 +2,7 @@ package create
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@ -299,3 +300,31 @@ func tokenHasWorkflowScope(resp *http.Response) bool {
return slices.Contains(strings.Split(scopes, ","), "workflow")
}
// isNewRelease checks if there are new commits since the latest release.
func isNewRelease(httpClient *http.Client, repo ghrepo.Interface) (bool, error) {
ctx := context.Background()
release, err := shared.FetchLatestRelease(ctx, httpClient, repo)
if err != nil {
if errors.Is(err, shared.ErrReleaseNotFound) {
return true, nil
} else {
return false, err
}
}
tagName := release.TagName
path := fmt.Sprintf("repos/%s/%s/compare/%s...HEAD?per_page=1", repo.RepoOwner(), repo.RepoName(), tagName)
var comparisonStatus struct {
Status string `json:"status"`
}
apiClient := api.NewClientFromHTTP(httpClient)
if err := apiClient.REST(repo.RepoHost(), "GET", path, nil, &comparisonStatus); err != nil {
return false, err
}
isNew := comparisonStatus.Status == "ahead"
return isNew, nil
}

View file

@ -124,7 +124,7 @@ func (rel *Release) ExportData(fields []string) map[string]interface{} {
return data
}
var errNotFound = errors.New("release not found")
var ErrReleaseNotFound = errors.New("release not found")
type fetchResult struct {
release *Release
@ -150,7 +150,7 @@ func FetchRelease(ctx context.Context, httpClient *http.Client, repo ghrepo.Inte
}()
res := <-results
if errors.Is(res.error, errNotFound) {
if errors.Is(res.error, ErrReleaseNotFound) {
res = <-results
cancel() // satisfy the linter even though no goroutines are running anymore
} else {
@ -190,7 +190,7 @@ func fetchDraftRelease(ctx context.Context, httpClient *http.Client, repo ghrepo
}
if query.Repository.Release == nil || !query.Repository.Release.IsDraft {
return nil, errNotFound
return nil, ErrReleaseNotFound
}
// Then, use REST to get information about the draft release. In theory, we could have fetched
@ -213,7 +213,7 @@ func fetchReleasePath(ctx context.Context, httpClient *http.Client, host string,
if resp.StatusCode == 404 {
_, _ = io.Copy(io.Discard, resp.Body)
return nil, errNotFound
return nil, ErrReleaseNotFound
} else if resp.StatusCode > 299 {
return nil, api.HandleHTTPError(resp)
}