[gh release create] Fail when there are no new commits since the most recent release

This commit is contained in:
Azeem Sajid 2025-02-08 18:20:43 +05:00
parent 55579582e2
commit 49fd7a5756
4 changed files with 172 additions and 4 deletions

View file

@ -60,6 +60,7 @@ type CreateOptions struct {
NotesStartTag string
VerifyTag bool
NotesFromTag bool
OnlyNew bool
}
func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
@ -194,6 +195,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.OnlyNew, "only-new", false, "Create release only if there are new commits since the last release")
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "target")
@ -211,6 +213,16 @@ func createRun(opts *CreateOptions) error {
return err
}
if opts.OnlyNew {
isNew, err := isNewRelease(httpClient, baseRepo)
if err != nil {
return 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

@ -63,6 +63,7 @@ func Test_NewCmdCreate(t *testing.T) {
Concurrency: 5,
Assets: []*shared.AssetForUpload(nil),
VerifyTag: false,
OnlyNew: false,
},
},
{
@ -87,6 +88,7 @@ func Test_NewCmdCreate(t *testing.T) {
Concurrency: 5,
Assets: []*shared.AssetForUpload(nil),
VerifyTag: false,
OnlyNew: 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 --only-new",
args: "v1.2.3 --only-new",
isTTY: false,
want: CreateOptions{
TagName: "v1.2.3",
BodyProvided: false,
Concurrency: 5,
Assets: []*shared.AssetForUpload(nil),
NotesFromTag: false,
OnlyNew: 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.OnlyNew, opts.OnlyNew)
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: "",
OnlyNew: 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: "",
OnlyNew: 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: "",
OnlyNew: 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,48 @@ 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)
url := ghinstance.RESTPrefix(repo.RepoHost()) + path
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return false, err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := httpClient.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return false, api.HandleHTTPError(resp)
}
type comparisonStatus struct {
Status string `json:"status"`
}
var cmpStatus comparisonStatus
if err := json.NewDecoder(resp.Body).Decode(&cmpStatus); err != nil {
return false, err
}
isNew := cmpStatus.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)
}