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:
commit
1710f7dace
4 changed files with 185 additions and 26 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue