Repository: cli/cli (go)

Issue:
`gh release create`: Fail when there are no new commits since the most recent release
### Describe the feature or problem you’d like to solve

Currently `gh release create` succeeds when there have been no new commits since the most recent release, creating a duplicate release. I would like `gh release create` to (optionally) fail when there have been no new commits since the most recent release.

### Proposed solution

Add a new option that causes `gh release create` to fail when there have been no new commits since the most recent release.

### Context

I created a duplicate release unintentionally when I hadn't noticed that there had been no new commits since the last release.

I now use a `schedule` GitHub action to create a new prerelease each night. It would create a duplicate release if there had been no new commits since the previous day.

### Workaround

```sh
git fetch origin tag $(gh release list -L1 | cut -f1)
if git describe --tags --exact-match origin/master; then
  echo 'Error: No new commits since the most recent release'
  exit 1
else
  gh release create --prerelease --generate-notes $(date +%Y.%m.%d)
fi
```

Pull request:
[gh release create] Fail when there are no new commits since the last release
Fixes #6059.

Introduce a new Boolean flag ~~`--only-new`~~ (`--fail-on-no-commits`) to create release only if new commits exist since the last release.
Only the latest published release is checked. The draft and pre-release releases are excluded.

### APIs Used

- [Get the latest release](https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release) `/repos/{owner}/{repo}/releases/latest`
- [Compare two commits](https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#compare-two-commits) `/repos/{owner}/{repo}/compare/{basehead}`

### Tests Cases

1. New commits exist but no published release exists - **release created** (exit code 0)
2. New commits exist and the last release exists too - **release created** (exit code 0)
3. No new commits exist but the last release exists - **release not created** (exit code 1)

#### Test Runs

```shell
# Test cases 1 and 2
$ bin/gh release create --repo iamazeem/test --fail-on-no-commits --generate-notes v1.2.3
https://github.com/iamazeem/test/releases/tag/v1.2.3

# Test case 3 (exit code 1)
$ bin/gh release create --repo iamazeem/test `--fail-on-no-commits` --generate-notes v1.2.3
no new commits since the last release
```

---

### UPDATE

During review discussions, the flag has been renamed to `--fail-on-no-commits`.

Patch:
diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go
index 8e77546d0..74880a9de 100644
--- a/pkg/cmd/release/create/create.go
+++ b/pkg/cmd/release/create/create.go
@@ -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)
diff --git a/pkg/cmd/release/create/http.go b/pkg/cmd/release/create/http.go
index 3bb55f39e..4311a3896 100644
--- a/pkg/cmd/release/create/http.go
+++ b/pkg/cmd/release/create/http.go
@@ -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
+}
diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go
index c9cf7a6a4..8db7e502a 100644
--- a/pkg/cmd/release/shared/fetch.go
+++ b/pkg/cmd/release/shared/fetch.go
@@ -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)
 	}


Test patch:
diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go
index c9e9a8c8a..a6e6cd7c6 100644
--- a/pkg/cmd/release/create/create_test.go
+++ b/pkg/cmd/release/create/create_test.go
@@ -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,