summaryrefslogtreecommitdiffstats
path: root/services/release
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-12-12 23:57:56 +0100
commite68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch)
tree97775d6c13b0f416af55314eb6a89ef792474615 /services/release
parentInitial commit. (diff)
downloadforgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz
forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
-rw-r--r--services/release/release.go470
-rw-r--r--services/release/release_test.go475
-rw-r--r--services/release/tag.go61
3 files changed, 1006 insertions, 0 deletions
diff --git a/services/release/release.go b/services/release/release.go
new file mode 100644
index 0000000..99851ed
--- /dev/null
+++ b/services/release/release.go
@@ -0,0 +1,470 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package release
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/container"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/repository"
+ "code.gitea.io/gitea/modules/storage"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/services/attachment"
+ notify_service "code.gitea.io/gitea/services/notify"
+)
+
+type AttachmentChange struct {
+ Action string // "add", "delete", "update
+ Type string // "attachment", "external"
+ UUID string
+ Name string
+ ExternalURL string
+}
+
+func createTag(ctx context.Context, gitRepo *git.Repository, rel *repo_model.Release, msg string) (bool, error) {
+ err := rel.LoadAttributes(ctx)
+ if err != nil {
+ return false, err
+ }
+
+ err = rel.Repo.MustNotBeArchived()
+ if err != nil {
+ return false, err
+ }
+
+ var created bool
+ // Only actual create when publish.
+ if !rel.IsDraft {
+ if !gitRepo.IsTagExist(rel.TagName) {
+ if err := rel.LoadAttributes(ctx); err != nil {
+ log.Error("LoadAttributes: %v", err)
+ return false, err
+ }
+
+ protectedTags, err := git_model.GetProtectedTags(ctx, rel.Repo.ID)
+ if err != nil {
+ return false, fmt.Errorf("GetProtectedTags: %w", err)
+ }
+
+ // Trim '--' prefix to prevent command line argument vulnerability.
+ rel.TagName = strings.TrimPrefix(rel.TagName, "--")
+ isAllowed, err := git_model.IsUserAllowedToControlTag(ctx, protectedTags, rel.TagName, rel.PublisherID)
+ if err != nil {
+ return false, err
+ }
+ if !isAllowed {
+ return false, models.ErrProtectedTagName{
+ TagName: rel.TagName,
+ }
+ }
+
+ commit, err := gitRepo.GetCommit(rel.Target)
+ if err != nil {
+ return false, err
+ }
+
+ if len(msg) > 0 {
+ if err = gitRepo.CreateAnnotatedTag(rel.TagName, msg, commit.ID.String()); err != nil {
+ if strings.Contains(err.Error(), "is not a valid tag name") {
+ return false, models.ErrInvalidTagName{
+ TagName: rel.TagName,
+ }
+ }
+ return false, err
+ }
+ } else if err = gitRepo.CreateTag(rel.TagName, commit.ID.String()); err != nil {
+ if strings.Contains(err.Error(), "is not a valid tag name") {
+ return false, models.ErrInvalidTagName{
+ TagName: rel.TagName,
+ }
+ }
+ return false, err
+ }
+ created = true
+ rel.LowerTagName = strings.ToLower(rel.TagName)
+
+ objectFormat := git.ObjectFormatFromName(rel.Repo.ObjectFormatName)
+ commits := repository.NewPushCommits()
+ commits.HeadCommit = repository.CommitToPushCommit(commit)
+ commits.CompareURL = rel.Repo.ComposeCompareURL(objectFormat.EmptyObjectID().String(), commit.ID.String())
+
+ refFullName := git.RefNameFromTag(rel.TagName)
+ notify_service.PushCommits(
+ ctx, rel.Publisher, rel.Repo,
+ &repository.PushUpdateOptions{
+ RefFullName: refFullName,
+ OldCommitID: objectFormat.EmptyObjectID().String(),
+ NewCommitID: commit.ID.String(),
+ }, commits)
+ notify_service.CreateRef(ctx, rel.Publisher, rel.Repo, refFullName, commit.ID.String())
+ rel.CreatedUnix = timeutil.TimeStampNow()
+ }
+ commit, err := gitRepo.GetTagCommit(rel.TagName)
+ if err != nil {
+ return false, fmt.Errorf("GetTagCommit: %w", err)
+ }
+
+ rel.Sha1 = commit.ID.String()
+ rel.NumCommits, err = commit.CommitsCount()
+ if err != nil {
+ return false, fmt.Errorf("CommitsCount: %w", err)
+ }
+
+ if rel.PublisherID <= 0 {
+ u, err := user_model.GetUserByEmail(ctx, commit.Author.Email)
+ if err == nil {
+ rel.PublisherID = u.ID
+ }
+ }
+ } else {
+ rel.CreatedUnix = timeutil.TimeStampNow()
+ }
+ return created, nil
+}
+
+// CreateRelease creates a new release of repository.
+func CreateRelease(gitRepo *git.Repository, rel *repo_model.Release, msg string, attachmentChanges []*AttachmentChange) error {
+ has, err := repo_model.IsReleaseExist(gitRepo.Ctx, rel.RepoID, rel.TagName)
+ if err != nil {
+ return err
+ } else if has {
+ return repo_model.ErrReleaseAlreadyExist{
+ TagName: rel.TagName,
+ }
+ }
+
+ if _, err = createTag(gitRepo.Ctx, gitRepo, rel, msg); err != nil {
+ return err
+ }
+
+ rel.LowerTagName = strings.ToLower(rel.TagName)
+ if err = db.Insert(gitRepo.Ctx, rel); err != nil {
+ return err
+ }
+
+ addAttachmentUUIDs := make(container.Set[string])
+
+ for _, attachmentChange := range attachmentChanges {
+ if attachmentChange.Action != "add" {
+ return fmt.Errorf("can only create new attachments when creating release")
+ }
+ switch attachmentChange.Type {
+ case "attachment":
+ if attachmentChange.UUID == "" {
+ return fmt.Errorf("new attachment should have a uuid")
+ }
+ addAttachmentUUIDs.Add(attachmentChange.UUID)
+ case "external":
+ if attachmentChange.Name == "" || attachmentChange.ExternalURL == "" {
+ return fmt.Errorf("new external attachment should have a name and external url")
+ }
+
+ _, err = attachment.NewExternalAttachment(gitRepo.Ctx, &repo_model.Attachment{
+ Name: attachmentChange.Name,
+ UploaderID: rel.PublisherID,
+ RepoID: rel.RepoID,
+ ReleaseID: rel.ID,
+ ExternalURL: attachmentChange.ExternalURL,
+ })
+ if err != nil {
+ return err
+ }
+ default:
+ if attachmentChange.Type == "" {
+ return fmt.Errorf("missing attachment type")
+ }
+ return fmt.Errorf("unknown attachment type: '%q'", attachmentChange.Type)
+ }
+ }
+
+ if err = repo_model.AddReleaseAttachments(gitRepo.Ctx, rel.ID, addAttachmentUUIDs.Values()); err != nil {
+ return err
+ }
+
+ if !rel.IsDraft {
+ notify_service.NewRelease(gitRepo.Ctx, rel)
+ }
+
+ return nil
+}
+
+// CreateNewTag creates a new repository tag
+func CreateNewTag(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, commit, tagName, msg string) error {
+ has, err := repo_model.IsReleaseExist(ctx, repo.ID, tagName)
+ if err != nil {
+ return err
+ } else if has {
+ return models.ErrTagAlreadyExists{
+ TagName: tagName,
+ }
+ }
+
+ gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
+ if err != nil {
+ return err
+ }
+ defer closer.Close()
+
+ rel := &repo_model.Release{
+ RepoID: repo.ID,
+ Repo: repo,
+ PublisherID: doer.ID,
+ Publisher: doer,
+ TagName: tagName,
+ Target: commit,
+ IsDraft: false,
+ IsPrerelease: false,
+ IsTag: true,
+ }
+
+ if _, err = createTag(ctx, gitRepo, rel, msg); err != nil {
+ return err
+ }
+
+ return db.Insert(ctx, rel)
+}
+
+// UpdateRelease updates information, attachments of a release and will create tag if it's not a draft and tag not exist.
+// addAttachmentUUIDs accept a slice of new created attachments' uuids which will be reassigned release_id as the created release
+// delAttachmentUUIDs accept a slice of attachments' uuids which will be deleted from the release
+// editAttachments accept a map of attachment uuid to new attachment name which will be updated with attachments.
+func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, rel *repo_model.Release, createdFromTag bool, attachmentChanges []*AttachmentChange,
+) error {
+ if rel.ID == 0 {
+ return errors.New("UpdateRelease only accepts an exist release")
+ }
+ isCreated, err := createTag(gitRepo.Ctx, gitRepo, rel, "")
+ if err != nil {
+ return err
+ }
+ rel.LowerTagName = strings.ToLower(rel.TagName)
+
+ ctx, committer, err := db.TxContext(ctx)
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ if err = repo_model.UpdateRelease(ctx, rel); err != nil {
+ return err
+ }
+
+ addAttachmentUUIDs := make(container.Set[string])
+ delAttachmentUUIDs := make(container.Set[string])
+ updateAttachmentUUIDs := make(container.Set[string])
+ updateAttachments := make(container.Set[*AttachmentChange])
+
+ for _, attachmentChange := range attachmentChanges {
+ switch attachmentChange.Action {
+ case "add":
+ switch attachmentChange.Type {
+ case "attachment":
+ if attachmentChange.UUID == "" {
+ return fmt.Errorf("new attachment should have a uuid (%s)}", attachmentChange.Name)
+ }
+ addAttachmentUUIDs.Add(attachmentChange.UUID)
+ case "external":
+ if attachmentChange.Name == "" || attachmentChange.ExternalURL == "" {
+ return fmt.Errorf("new external attachment should have a name and external url")
+ }
+ _, err := attachment.NewExternalAttachment(ctx, &repo_model.Attachment{
+ Name: attachmentChange.Name,
+ UploaderID: doer.ID,
+ RepoID: rel.RepoID,
+ ReleaseID: rel.ID,
+ ExternalURL: attachmentChange.ExternalURL,
+ })
+ if err != nil {
+ return err
+ }
+ default:
+ if attachmentChange.Type == "" {
+ return fmt.Errorf("missing attachment type")
+ }
+ return fmt.Errorf("unknown attachment type: %q", attachmentChange.Type)
+ }
+ case "delete":
+ if attachmentChange.UUID == "" {
+ return fmt.Errorf("attachment deletion should have a uuid")
+ }
+ delAttachmentUUIDs.Add(attachmentChange.UUID)
+ case "update":
+ updateAttachmentUUIDs.Add(attachmentChange.UUID)
+ updateAttachments.Add(attachmentChange)
+ default:
+ if attachmentChange.Action == "" {
+ return fmt.Errorf("missing attachment action")
+ }
+ return fmt.Errorf("unknown attachment action: %q", attachmentChange.Action)
+ }
+ }
+
+ if err = repo_model.AddReleaseAttachments(ctx, rel.ID, addAttachmentUUIDs.Values()); err != nil {
+ return fmt.Errorf("AddReleaseAttachments: %w", err)
+ }
+
+ deletedUUIDs := make(container.Set[string])
+ if len(delAttachmentUUIDs) > 0 {
+ // Check attachments
+ attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, delAttachmentUUIDs.Values())
+ if err != nil {
+ return fmt.Errorf("GetAttachmentsByUUIDs [uuids: %v]: %w", delAttachmentUUIDs, err)
+ }
+ for _, attach := range attachments {
+ if attach.ReleaseID != rel.ID {
+ return util.SilentWrap{
+ Message: "delete attachment of release permission denied",
+ Err: util.ErrPermissionDenied,
+ }
+ }
+ deletedUUIDs.Add(attach.UUID)
+ }
+
+ if _, err := repo_model.DeleteAttachments(ctx, attachments, true); err != nil {
+ return fmt.Errorf("DeleteAttachments [uuids: %v]: %w", delAttachmentUUIDs, err)
+ }
+ }
+
+ if len(updateAttachmentUUIDs) > 0 {
+ // Check attachments
+ attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, updateAttachmentUUIDs.Values())
+ if err != nil {
+ return fmt.Errorf("GetAttachmentsByUUIDs [uuids: %v]: %w", updateAttachmentUUIDs, err)
+ }
+ for _, attach := range attachments {
+ if attach.ReleaseID != rel.ID {
+ return util.SilentWrap{
+ Message: "update attachment of release permission denied",
+ Err: util.ErrPermissionDenied,
+ }
+ }
+ }
+ }
+
+ for attachmentChange := range updateAttachments {
+ if !deletedUUIDs.Contains(attachmentChange.UUID) {
+ if err = repo_model.UpdateAttachmentByUUID(ctx, &repo_model.Attachment{
+ UUID: attachmentChange.UUID,
+ Name: attachmentChange.Name,
+ ExternalURL: attachmentChange.ExternalURL,
+ }, "name", "external_url"); err != nil {
+ return err
+ }
+ }
+ }
+
+ if err := committer.Commit(); err != nil {
+ return err
+ }
+
+ for _, uuid := range delAttachmentUUIDs.Values() {
+ if err := storage.Attachments.Delete(repo_model.AttachmentRelativePath(uuid)); err != nil {
+ // Even delete files failed, but the attachments has been removed from database, so we
+ // should not return error but only record the error on logs.
+ // users have to delete this attachments manually or we should have a
+ // synchronize between database attachment table and attachment storage
+ log.Error("delete attachment[uuid: %s] failed: %v", uuid, err)
+ }
+ }
+
+ if !rel.IsDraft {
+ if createdFromTag || isCreated {
+ notify_service.NewRelease(gitRepo.Ctx, rel)
+ return nil
+ }
+ notify_service.UpdateRelease(gitRepo.Ctx, doer, rel)
+ }
+ return nil
+}
+
+// DeleteReleaseByID deletes a release and corresponding Git tag by given ID.
+func DeleteReleaseByID(ctx context.Context, repo *repo_model.Repository, rel *repo_model.Release, doer *user_model.User, delTag bool) error {
+ if delTag {
+ protectedTags, err := git_model.GetProtectedTags(ctx, rel.RepoID)
+ if err != nil {
+ return fmt.Errorf("GetProtectedTags: %w", err)
+ }
+ isAllowed, err := git_model.IsUserAllowedToControlTag(ctx, protectedTags, rel.TagName, rel.PublisherID)
+ if err != nil {
+ return err
+ }
+ if !isAllowed {
+ return models.ErrProtectedTagName{
+ TagName: rel.TagName,
+ }
+ }
+
+ err = repo_model.DeleteArchiveDownloadCountForRelease(ctx, rel.ID)
+ if err != nil {
+ return err
+ }
+
+ if stdout, _, err := git.NewCommand(ctx, "tag", "-d").AddDashesAndList(rel.TagName).
+ SetDescription(fmt.Sprintf("DeleteReleaseByID (git tag -d): %d", rel.ID)).
+ RunStdString(&git.RunOpts{Dir: repo.RepoPath()}); err != nil && !strings.Contains(err.Error(), "not found") {
+ log.Error("DeleteReleaseByID (git tag -d): %d in %v Failed:\nStdout: %s\nError: %v", rel.ID, repo, stdout, err)
+ return fmt.Errorf("git tag -d: %w", err)
+ }
+
+ refName := git.RefNameFromTag(rel.TagName)
+ objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName)
+ notify_service.PushCommits(
+ ctx, doer, repo,
+ &repository.PushUpdateOptions{
+ RefFullName: refName,
+ OldCommitID: rel.Sha1,
+ NewCommitID: objectFormat.EmptyObjectID().String(),
+ }, repository.NewPushCommits())
+ notify_service.DeleteRef(ctx, doer, repo, refName)
+
+ if _, err := db.DeleteByID[repo_model.Release](ctx, rel.ID); err != nil {
+ return fmt.Errorf("DeleteReleaseByID: %w", err)
+ }
+ } else {
+ rel.IsTag = true
+
+ if err := repo_model.UpdateRelease(ctx, rel); err != nil {
+ return fmt.Errorf("Update: %w", err)
+ }
+ }
+
+ rel.Repo = repo
+ if err := rel.LoadAttributes(ctx); err != nil {
+ return fmt.Errorf("LoadAttributes: %w", err)
+ }
+
+ if err := repo_model.DeleteAttachmentsByRelease(ctx, rel.ID); err != nil {
+ return fmt.Errorf("DeleteAttachments: %w", err)
+ }
+
+ for i := range rel.Attachments {
+ attachment := rel.Attachments[i]
+ if err := storage.Attachments.Delete(attachment.RelativePath()); err != nil {
+ log.Error("Delete attachment %s of release %s failed: %v", attachment.UUID, rel.ID, err)
+ }
+ }
+
+ if !rel.IsDraft {
+ notify_service.DeleteRelease(ctx, doer, rel)
+ }
+ return nil
+}
+
+// Init start release service
+func Init() error {
+ return initTagSyncQueue(graceful.GetManager().ShutdownContext())
+}
diff --git a/services/release/release_test.go b/services/release/release_test.go
new file mode 100644
index 0000000..026bba8
--- /dev/null
+++ b/services/release/release_test.go
@@ -0,0 +1,475 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package release
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/services/attachment"
+
+ _ "code.gitea.io/gitea/models/actions"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m)
+}
+
+func TestRelease_Create(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+
+ gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ require.NoError(t, CreateRelease(gitRepo, &repo_model.Release{
+ RepoID: repo.ID,
+ Repo: repo,
+ PublisherID: user.ID,
+ Publisher: user,
+ TagName: "v0.1",
+ Target: "master",
+ Title: "v0.1 is released",
+ Note: "v0.1 is released",
+ IsDraft: false,
+ IsPrerelease: false,
+ IsTag: false,
+ }, "", []*AttachmentChange{}))
+
+ require.NoError(t, CreateRelease(gitRepo, &repo_model.Release{
+ RepoID: repo.ID,
+ Repo: repo,
+ PublisherID: user.ID,
+ Publisher: user,
+ TagName: "v0.1.1",
+ Target: "65f1bf27bc3bf70f64657658635e66094edbcb4d",
+ Title: "v0.1.1 is released",
+ Note: "v0.1.1 is released",
+ IsDraft: false,
+ IsPrerelease: false,
+ IsTag: false,
+ }, "", []*AttachmentChange{}))
+
+ require.NoError(t, CreateRelease(gitRepo, &repo_model.Release{
+ RepoID: repo.ID,
+ Repo: repo,
+ PublisherID: user.ID,
+ Publisher: user,
+ TagName: "v0.1.2",
+ Target: "65f1bf2",
+ Title: "v0.1.2 is released",
+ Note: "v0.1.2 is released",
+ IsDraft: false,
+ IsPrerelease: false,
+ IsTag: false,
+ }, "", []*AttachmentChange{}))
+
+ require.NoError(t, CreateRelease(gitRepo, &repo_model.Release{
+ RepoID: repo.ID,
+ Repo: repo,
+ PublisherID: user.ID,
+ Publisher: user,
+ TagName: "v0.1.3",
+ Target: "65f1bf2",
+ Title: "v0.1.3 is released",
+ Note: "v0.1.3 is released",
+ IsDraft: true,
+ IsPrerelease: false,
+ IsTag: false,
+ }, "", []*AttachmentChange{}))
+
+ require.NoError(t, CreateRelease(gitRepo, &repo_model.Release{
+ RepoID: repo.ID,
+ Repo: repo,
+ PublisherID: user.ID,
+ Publisher: user,
+ TagName: "v0.1.4",
+ Target: "65f1bf2",
+ Title: "v0.1.4 is released",
+ Note: "v0.1.4 is released",
+ IsDraft: false,
+ IsPrerelease: true,
+ IsTag: false,
+ }, "", []*AttachmentChange{}))
+
+ testPlayload := "testtest"
+
+ attach, err := attachment.NewAttachment(db.DefaultContext, &repo_model.Attachment{
+ RepoID: repo.ID,
+ UploaderID: user.ID,
+ Name: "test.txt",
+ }, strings.NewReader(testPlayload), int64(len([]byte(testPlayload))))
+ require.NoError(t, err)
+
+ release := repo_model.Release{
+ RepoID: repo.ID,
+ Repo: repo,
+ PublisherID: user.ID,
+ Publisher: user,
+ TagName: "v0.1.5",
+ Target: "65f1bf2",
+ Title: "v0.1.5 is released",
+ Note: "v0.1.5 is released",
+ IsDraft: false,
+ IsPrerelease: false,
+ IsTag: true,
+ }
+ require.NoError(t, CreateRelease(gitRepo, &release, "test", []*AttachmentChange{
+ {
+ Action: "add",
+ Type: "attachment",
+ UUID: attach.UUID,
+ },
+ }))
+ assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, &release))
+ assert.Len(t, release.Attachments, 1)
+ assert.EqualValues(t, attach.UUID, release.Attachments[0].UUID)
+ assert.EqualValues(t, attach.Name, release.Attachments[0].Name)
+ assert.EqualValues(t, attach.ExternalURL, release.Attachments[0].ExternalURL)
+
+ release = repo_model.Release{
+ RepoID: repo.ID,
+ Repo: repo,
+ PublisherID: user.ID,
+ Publisher: user,
+ TagName: "v0.1.6",
+ Target: "65f1bf2",
+ Title: "v0.1.6 is released",
+ Note: "v0.1.6 is released",
+ IsDraft: false,
+ IsPrerelease: false,
+ IsTag: true,
+ }
+ assert.NoError(t, CreateRelease(gitRepo, &release, "", []*AttachmentChange{
+ {
+ Action: "add",
+ Type: "external",
+ Name: "test",
+ ExternalURL: "https://forgejo.org/",
+ },
+ }))
+ assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, &release))
+ assert.Len(t, release.Attachments, 1)
+ assert.EqualValues(t, "test", release.Attachments[0].Name)
+ assert.EqualValues(t, "https://forgejo.org/", release.Attachments[0].ExternalURL)
+
+ release = repo_model.Release{
+ RepoID: repo.ID,
+ Repo: repo,
+ PublisherID: user.ID,
+ Publisher: user,
+ TagName: "v0.1.7",
+ Target: "65f1bf2",
+ Title: "v0.1.7 is released",
+ Note: "v0.1.7 is released",
+ IsDraft: false,
+ IsPrerelease: false,
+ IsTag: true,
+ }
+ assert.Error(t, CreateRelease(gitRepo, &repo_model.Release{}, "", []*AttachmentChange{
+ {
+ Action: "add",
+ Type: "external",
+ Name: "Click me",
+ // Invalid URL (API URL of current instance), this should result in an error
+ ExternalURL: "https://try.gitea.io/api/v1/user/follow",
+ },
+ }))
+}
+
+func TestRelease_Update(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+
+ gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ // Test a changed release
+ require.NoError(t, CreateRelease(gitRepo, &repo_model.Release{
+ RepoID: repo.ID,
+ Repo: repo,
+ PublisherID: user.ID,
+ Publisher: user,
+ TagName: "v1.1.1",
+ Target: "master",
+ Title: "v1.1.1 is released",
+ Note: "v1.1.1 is released",
+ IsDraft: false,
+ IsPrerelease: false,
+ IsTag: false,
+ }, "", []*AttachmentChange{}))
+ release, err := repo_model.GetRelease(db.DefaultContext, repo.ID, "v1.1.1")
+ require.NoError(t, err)
+ releaseCreatedUnix := release.CreatedUnix
+ time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp
+ release.Note = "Changed note"
+ require.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{}))
+ release, err = repo_model.GetReleaseByID(db.DefaultContext, release.ID)
+ require.NoError(t, err)
+ assert.Equal(t, int64(releaseCreatedUnix), int64(release.CreatedUnix))
+
+ // Test a changed draft
+ require.NoError(t, CreateRelease(gitRepo, &repo_model.Release{
+ RepoID: repo.ID,
+ Repo: repo,
+ PublisherID: user.ID,
+ Publisher: user,
+ TagName: "v1.2.1",
+ Target: "65f1bf2",
+ Title: "v1.2.1 is draft",
+ Note: "v1.2.1 is draft",
+ IsDraft: true,
+ IsPrerelease: false,
+ IsTag: false,
+ }, "", []*AttachmentChange{}))
+ release, err = repo_model.GetRelease(db.DefaultContext, repo.ID, "v1.2.1")
+ require.NoError(t, err)
+ releaseCreatedUnix = release.CreatedUnix
+ time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp
+ release.Title = "Changed title"
+ require.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{}))
+ release, err = repo_model.GetReleaseByID(db.DefaultContext, release.ID)
+ require.NoError(t, err)
+ assert.Less(t, int64(releaseCreatedUnix), int64(release.CreatedUnix))
+
+ // Test a changed pre-release
+ require.NoError(t, CreateRelease(gitRepo, &repo_model.Release{
+ RepoID: repo.ID,
+ Repo: repo,
+ PublisherID: user.ID,
+ Publisher: user,
+ TagName: "v1.3.1",
+ Target: "65f1bf2",
+ Title: "v1.3.1 is pre-released",
+ Note: "v1.3.1 is pre-released",
+ IsDraft: false,
+ IsPrerelease: true,
+ IsTag: false,
+ }, "", []*AttachmentChange{}))
+ release, err = repo_model.GetRelease(db.DefaultContext, repo.ID, "v1.3.1")
+ require.NoError(t, err)
+ releaseCreatedUnix = release.CreatedUnix
+ time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp
+ release.Title = "Changed title"
+ release.Note = "Changed note"
+ require.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{}))
+ release, err = repo_model.GetReleaseByID(db.DefaultContext, release.ID)
+ require.NoError(t, err)
+ assert.Equal(t, int64(releaseCreatedUnix), int64(release.CreatedUnix))
+
+ // Test create release
+ release = &repo_model.Release{
+ RepoID: repo.ID,
+ Repo: repo,
+ PublisherID: user.ID,
+ Publisher: user,
+ TagName: "v1.1.2",
+ Target: "master",
+ Title: "v1.1.2 is released",
+ Note: "v1.1.2 is released",
+ IsDraft: true,
+ IsPrerelease: false,
+ IsTag: false,
+ }
+ require.NoError(t, CreateRelease(gitRepo, release, "", []*AttachmentChange{}))
+ assert.Positive(t, release.ID)
+
+ release.IsDraft = false
+ tagName := release.TagName
+
+ require.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{}))
+ release, err = repo_model.GetReleaseByID(db.DefaultContext, release.ID)
+ require.NoError(t, err)
+ assert.Equal(t, tagName, release.TagName)
+
+ // Add new attachments
+ samplePayload := "testtest"
+ attach, err := attachment.NewAttachment(db.DefaultContext, &repo_model.Attachment{
+ RepoID: repo.ID,
+ UploaderID: user.ID,
+ Name: "test.txt",
+ }, strings.NewReader(samplePayload), int64(len([]byte(samplePayload))))
+ require.NoError(t, err)
+
+ require.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{
+ {
+ Action: "add",
+ Type: "attachment",
+ UUID: attach.UUID,
+ },
+ }))
+ require.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, release))
+ assert.Len(t, release.Attachments, 1)
+ assert.EqualValues(t, attach.UUID, release.Attachments[0].UUID)
+ assert.EqualValues(t, release.ID, release.Attachments[0].ReleaseID)
+ assert.EqualValues(t, attach.Name, release.Attachments[0].Name)
+ assert.EqualValues(t, attach.ExternalURL, release.Attachments[0].ExternalURL)
+
+ // update the attachment name
+ require.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{
+ {
+ Action: "update",
+ Name: "test2.txt",
+ UUID: attach.UUID,
+ },
+ }))
+ release.Attachments = nil
+ require.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, release))
+ assert.Len(t, release.Attachments, 1)
+ assert.EqualValues(t, attach.UUID, release.Attachments[0].UUID)
+ assert.EqualValues(t, release.ID, release.Attachments[0].ReleaseID)
+ assert.EqualValues(t, "test2.txt", release.Attachments[0].Name)
+ assert.EqualValues(t, attach.ExternalURL, release.Attachments[0].ExternalURL)
+
+ // delete the attachment
+ require.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{
+ {
+ Action: "delete",
+ UUID: attach.UUID,
+ },
+ }))
+ release.Attachments = nil
+ assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, release))
+ assert.Empty(t, release.Attachments)
+
+ // Add new external attachment
+ assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{
+ {
+ Action: "add",
+ Type: "external",
+ Name: "test",
+ ExternalURL: "https://forgejo.org/",
+ },
+ }))
+ assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, release))
+ assert.Len(t, release.Attachments, 1)
+ assert.EqualValues(t, release.ID, release.Attachments[0].ReleaseID)
+ assert.EqualValues(t, "test", release.Attachments[0].Name)
+ assert.EqualValues(t, "https://forgejo.org/", release.Attachments[0].ExternalURL)
+ externalAttachmentUUID := release.Attachments[0].UUID
+
+ // update the attachment name
+ assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{
+ {
+ Action: "update",
+ Name: "test2",
+ UUID: externalAttachmentUUID,
+ ExternalURL: "https://about.gitea.com/",
+ },
+ }))
+ release.Attachments = nil
+ assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, release))
+ assert.Len(t, release.Attachments, 1)
+ assert.EqualValues(t, externalAttachmentUUID, release.Attachments[0].UUID)
+ assert.EqualValues(t, release.ID, release.Attachments[0].ReleaseID)
+ assert.EqualValues(t, "test2", release.Attachments[0].Name)
+ assert.EqualValues(t, "https://about.gitea.com/", release.Attachments[0].ExternalURL)
+}
+
+func TestRelease_createTag(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+
+ gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ // Test a changed release
+ release := &repo_model.Release{
+ RepoID: repo.ID,
+ Repo: repo,
+ PublisherID: user.ID,
+ Publisher: user,
+ TagName: "v2.1.1",
+ Target: "master",
+ Title: "v2.1.1 is released",
+ Note: "v2.1.1 is released",
+ IsDraft: false,
+ IsPrerelease: false,
+ IsTag: false,
+ }
+ _, err = createTag(db.DefaultContext, gitRepo, release, "")
+ require.NoError(t, err)
+ assert.NotEmpty(t, release.CreatedUnix)
+ releaseCreatedUnix := release.CreatedUnix
+ time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp
+ release.Note = "Changed note"
+ _, err = createTag(db.DefaultContext, gitRepo, release, "")
+ require.NoError(t, err)
+ assert.Equal(t, int64(releaseCreatedUnix), int64(release.CreatedUnix))
+
+ // Test a changed draft
+ release = &repo_model.Release{
+ RepoID: repo.ID,
+ Repo: repo,
+ PublisherID: user.ID,
+ Publisher: user,
+ TagName: "v2.2.1",
+ Target: "65f1bf2",
+ Title: "v2.2.1 is draft",
+ Note: "v2.2.1 is draft",
+ IsDraft: true,
+ IsPrerelease: false,
+ IsTag: false,
+ }
+ _, err = createTag(db.DefaultContext, gitRepo, release, "")
+ require.NoError(t, err)
+ releaseCreatedUnix = release.CreatedUnix
+ time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp
+ release.Title = "Changed title"
+ _, err = createTag(db.DefaultContext, gitRepo, release, "")
+ require.NoError(t, err)
+ assert.Less(t, int64(releaseCreatedUnix), int64(release.CreatedUnix))
+
+ // Test a changed pre-release
+ release = &repo_model.Release{
+ RepoID: repo.ID,
+ Repo: repo,
+ PublisherID: user.ID,
+ Publisher: user,
+ TagName: "v2.3.1",
+ Target: "65f1bf2",
+ Title: "v2.3.1 is pre-released",
+ Note: "v2.3.1 is pre-released",
+ IsDraft: false,
+ IsPrerelease: true,
+ IsTag: false,
+ }
+ _, err = createTag(db.DefaultContext, gitRepo, release, "")
+ require.NoError(t, err)
+ releaseCreatedUnix = release.CreatedUnix
+ time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp
+ release.Title = "Changed title"
+ release.Note = "Changed note"
+ _, err = createTag(db.DefaultContext, gitRepo, release, "")
+ require.NoError(t, err)
+ assert.Equal(t, int64(releaseCreatedUnix), int64(release.CreatedUnix))
+}
+
+func TestCreateNewTag(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+
+ require.NoError(t, CreateNewTag(git.DefaultContext, user, repo, "master", "v2.0",
+ "v2.0 is released \n\n BUGFIX: .... \n\n 123"))
+}
diff --git a/services/release/tag.go b/services/release/tag.go
new file mode 100644
index 0000000..dae2b70
--- /dev/null
+++ b/services/release/tag.go
@@ -0,0 +1,61 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package release
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/queue"
+ repo_module "code.gitea.io/gitea/modules/repository"
+
+ "xorm.io/builder"
+)
+
+type TagSyncOptions struct {
+ RepoID int64
+}
+
+// tagSyncQueue represents a queue to handle tag sync jobs.
+var tagSyncQueue *queue.WorkerPoolQueue[*TagSyncOptions]
+
+func handlerTagSync(items ...*TagSyncOptions) []*TagSyncOptions {
+ for _, opts := range items {
+ err := repo_module.SyncRepoTags(graceful.GetManager().ShutdownContext(), opts.RepoID)
+ if err != nil {
+ log.Error("syncRepoTags [%d] failed: %v", opts.RepoID, err)
+ }
+ }
+ return nil
+}
+
+func addRepoToTagSyncQueue(repoID int64) error {
+ return tagSyncQueue.Push(&TagSyncOptions{
+ RepoID: repoID,
+ })
+}
+
+func initTagSyncQueue(ctx context.Context) error {
+ tagSyncQueue = queue.CreateUniqueQueue(ctx, "tag_sync", handlerTagSync)
+ if tagSyncQueue == nil {
+ return errors.New("unable to create tag_sync queue")
+ }
+ go graceful.GetManager().RunWithCancel(tagSyncQueue)
+
+ return nil
+}
+
+func AddAllRepoTagsToSyncQueue(ctx context.Context) error {
+ if err := db.Iterate(ctx, builder.Eq{"is_empty": false}, func(ctx context.Context, repo *repo_model.Repository) error {
+ return addRepoToTagSyncQueue(repo.ID)
+ }); err != nil {
+ return fmt.Errorf("run sync all tags failed: %v", err)
+ }
+ return nil
+}