fix(release): report an unknown target clearly, hint to push, and add --verify-tag (follow gh)

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-18 19:01:19 +02:00
1 parent 9360f39cc8
commit c97044f76c
4 files changed
+142 -1

No files matched your search

+67 -1
View File
@@ -5,6 +5,7 @@ package app
import (
"cmp"
"context"
"errors"
"fmt"
"path/filepath"
@@ -220,12 +221,16 @@ func (f *releaseFields) options(a *application, cmd *cobra.Command) (services.Re
func (a *application) releaseCreateCommand() *cobra.Command {
var fields releaseFields
var assets []string
var verifyTag bool
command := &cobra.Command{
Use: "create [<tag>] [<file>...]",
Aliases: []string{"new"},
Short: "Create a release",
Long: `Create a release for a tag, and upload the files that follow the tag as its assets. Gitea creates
the tag at --target (the default branch) when it does not exist yet. The title defaults to the tag.`,
the tag at --target (the default branch) when it does not exist yet. The title defaults to the tag.
tea never pushes: commits and tags that only exist in your clone are not on the server, so push them
first (git push <remote> <tag>), or pass --verify-tag to fail when the tag is not on the server yet.`,
Example: `# Create a release with notes from a file and two assets
$ tea release create v1.0.0 dist/tea-linux dist/tea-darwin --notes-file CHANGELOG.md
@@ -267,6 +272,9 @@ $ tea release create v1.1.0-rc1 --target release/1.1 --draft --prerelease --titl
return err
}
service := services.Releases{Client: target.client, Repository: target.Repo}
if err := a.checkReleaseTag(cmd, target, service, fields.tag, opts.Target != nil, verifyTag); err != nil {
return err
}
if fields.editor {
if editorDraft, err = a.editDraft(cmd, ""); err != nil {
return err
@@ -279,6 +287,11 @@ $ tea release create v1.1.0-rc1 --target release/1.1 --draft --prerelease --titl
}
}
item, err := service.Create(cmd.Context(), opts)
if notFound, ok := errors.AsType[*services.NotFoundError](err); ok && notFound.Kind == "target" {
if hint := a.pushHint(cmd.Context(), target, *opts.Target); hint != "" {
err = explain(err, "%s; %s", err, hint)
}
}
if err != nil {
return err
}
@@ -305,10 +318,63 @@ $ tea release create v1.1.0-rc1 --target release/1.1 --draft --prerelease --titl
command.Flags().StringVar(&fields.tag, "tag", "", "Release tag")
_ = command.Flags().MarkHidden("tag")
command.Flags().StringSliceVar(&assets, "asset", nil, "File to upload as an asset (repeatable)")
command.Flags().BoolVar(&verifyTag, "verify-tag", false, "Fail if the tag does not exist on the server yet")
command.ValidArgsFunction = pathArgs(1, true, completeFiles, a.completeTags())
return command
}
// checkReleaseTag guards against a release whose tag Gitea would create from the default branch
// instead of from the local commit the tag names: --verify-tag refuses a tag the server lacks, and
// without it a tag that only exists locally is warned about.
func (a *application) checkReleaseTag(cmd *cobra.Command, t target, service services.Releases, tag string, hasTarget, verify bool) error {
local := a.localRefExists(cmd.Context(), "refs/tags/"+tag)
if !verify && (!local || hasTarget) {
return nil
}
exists, err := service.TagExists(cmd.Context(), tag)
if err != nil || exists {
return err
}
hint := ""
if local {
hint = "push it first: git push " + a.releaseRemote(t) + " " + tag
}
if verify {
missing := services.NotFound("tag", tag, t.Repo)
return explain(missing, "%s; %s", missing, cmp.Or(hint, "create it first or drop --verify-tag"))
}
a.warn(cmd, "Tag %s exists in your clone but not in %s; the release creates it at the default branch, not at your local commit; %s", tag, t.Repo, hint)
return nil
}
// pushHint tells how to publish a ref that only the local clone has, or returns "".
func (a *application) pushHint(ctx context.Context, t target, ref string) string {
remote := a.releaseRemote(t)
for _, prefix := range []string{"refs/tags/", "refs/heads/"} {
if a.localRefExists(ctx, prefix+ref) {
return "push it first: git push " + remote + " " + ref
}
}
if ref != "" && a.localRefExists(ctx, ref+"^{commit}") {
return "push the branch that has this commit first: git push " + remote
}
return ""
}
// localRefExists reports whether the working directory's checkout resolves ref.
func (a *application) localRefExists(ctx context.Context, ref string) bool {
_, err := a.gitProbe().Output(ctx, a.options.cwd, false, "rev-parse", "--verify", "--quiet", ref)
return err == nil
}
// releaseRemote is the remote of the target repository, else origin.
func (a *application) releaseRemote(t target) string {
if remote, ok := strings.CutPrefix(t.RepoSource, "remote "); ok {
return remote
}
return cmp.Or(a.options.remote, "origin")
}
// rejectSwitchValue refuses the old tea syntax "--draft false": a switch reads no value, so the
// word would be edited as one more release after the switch was applied as true.
func rejectSwitchValue(cmd *cobra.Command, args []string, switches ...string) error {
+30
View File
@@ -104,3 +104,33 @@ func TestReleaseCreateUploadsPositionalFiles(t *testing.T) {
require.Equal(t, "https://gitea.test/alice/project/releases/tag/v1.1.0\n", out.String())
require.Equal(t, "✓ Created draft release v1.1.0 in alice/project\n✓ Uploaded tea.bin (2 B)\n", diagnostics.String())
}
func TestReleaseCreateWithUnknownTargetNamesTheTargetAndHintsAtPushing(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"target not found"}`))
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
deps.Git = &fakeGit{results: map[string]string{"rev-parse --verify --quiet refs/tags/v2": "abc"}}
err := Execute(context.Background(), deps, []string{"release", "create", "v1", "--target", "v2", "-R", "alice/project"})
require.EqualError(t, Explain(context.Background(), deps, err), `target "v2" not found in alice/project; push it first: git push origin v2`)
deps.Git = &fakeGit{}
err = Execute(context.Background(), deps, []string{"release", "create", "v1", "--target", "nowhere", "-R", "alice/project"})
require.EqualError(t, Explain(context.Background(), deps, err), `target "nowhere" not found in alice/project`)
}
func TestReleaseCreateVerifyTagRefusesATagTheServerLacks(t *testing.T) {
var requests []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, r.Method+" "+r.URL.Path)
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"tag does not exist"}`))
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
deps.Git = &fakeGit{results: map[string]string{"rev-parse --verify --quiet refs/tags/v1": "abc"}}
err := Execute(context.Background(), deps, []string{"release", "create", "v1", "--verify-tag", "-R", "alice/project"})
require.EqualError(t, err, "tag v1 not found in alice/project; push it first: git push origin v1")
require.Equal(t, []string{"GET /api/v1/repos/alice/project/tags/v1"}, requests)
}
+18
View File
@@ -5,9 +5,12 @@ package services
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
gitea "gitea.dev/sdk"
"gitea.dev/tea/internal/transport"
@@ -103,9 +106,24 @@ func (s Releases) Create(ctx context.Context, opts ReleaseOptions) (Release, err
IsDraft: deref(opts.Draft), IsPrerelease: deref(opts.Prerelease),
})
})
if apiErr, ok := errors.AsType[*transport.APIError](err); ok && apiErr.Status == http.StatusNotFound && strings.Contains(apiErr.Message, "target") {
// The release does not exist yet, so a 404 here names the unresolvable target, not the release.
return item, &NotFoundError{Kind: "target", Key: strconv.Quote(deref(opts.Target)), Scope: s.Repository, Err: err}
}
return item, Classify(err, "release", deref(opts.Tag), s.Repository)
}
// TagExists reports whether the repository on the server has the tag.
func (s Releases) TagExists(ctx context.Context, tag string) (bool, error) {
_, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Tag, *gitea.Response, error) {
return c.Repositories.GetTag(ctx, owner, repo, tag)
})
if IsNotFound(err) {
return false, nil
}
return err == nil, err
}
// Edit changes only supplied release fields; Gitea ignores empty text fields.
func (s Releases) Edit(ctx context.Context, id int64, opts ReleaseOptions) (Release, error) {
return repoConvert(ctx, s.Client, s.Repository, releaseFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Release, *gitea.Response, error) {
+27
View File
@@ -234,3 +234,30 @@ func TestReleaseAssetRenameAndDelete(t *testing.T) {
require.Equal(t, ReleaseAsset{ID: 2, Name: "renamed.bin"}, asset)
require.NoError(t, service.DeleteAsset(context.Background(), 1, 2))
}
func TestReleaseCreateReportsAnUnresolvableTargetNotAMissingRelease(t *testing.T) {
client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"target not found"}`))
}))
target, tag := "deadbeef", "v1"
_, err := Releases{Client: client, Repository: "alice/project"}.Create(context.Background(), ReleaseOptions{Tag: &tag, Title: &tag, Target: &target})
var notFound *NotFoundError
require.ErrorAs(t, err, &notFound)
require.EqualError(t, err, `target "deadbeef" not found in alice/project`)
}
func TestReleaseTagExistsDistinguishesMissingFromFailing(t *testing.T) {
status := http.StatusNotFound
client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(`{"message":"x"}`))
}))
service := Releases{Client: client, Repository: "alice/project"}
exists, err := service.TagExists(context.Background(), "v1")
require.NoError(t, err)
require.False(t, exists)
status = http.StatusInternalServerError
_, err = service.TagExists(context.Background(), "v1")
require.Error(t, err)
}