diff --git a/models/forgejo_migrations/migrate.go b/models/forgejo_migrations/migrate.go
index 78c13f33a0..5ca12a99e1 100644
--- a/models/forgejo_migrations/migrate.go
+++ b/models/forgejo_migrations/migrate.go
@@ -74,6 +74,8 @@ var migrations = []*Migration{
 	NewMigration("Add `normalized_federated_uri` column to `user` table", AddNormalizedFederatedURIToUser),
 	// v18 -> v19
 	NewMigration("Create the `following_repo` table", CreateFollowingRepoTable),
+	// v19 -> v20
+	NewMigration("Add external_url to attachment table", AddExternalURLColumnToAttachmentTable),
 }
 
 // GetCurrentDBVersion returns the current Forgejo database version.
diff --git a/models/forgejo_migrations/v19.go b/models/forgejo_migrations/v19.go
new file mode 100644
index 0000000000..69b7746eb1
--- /dev/null
+++ b/models/forgejo_migrations/v19.go
@@ -0,0 +1,14 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package forgejo_migrations //nolint:revive
+
+import "xorm.io/xorm"
+
+func AddExternalURLColumnToAttachmentTable(x *xorm.Engine) error {
+	type Attachment struct {
+		ID          int64 `xorm:"pk autoincr"`
+		ExternalURL string
+	}
+	return x.Sync(new(Attachment))
+}
diff --git a/models/repo/attachment.go b/models/repo/attachment.go
index 546e409de7..128bcebb60 100644
--- a/models/repo/attachment.go
+++ b/models/repo/attachment.go
@@ -14,6 +14,7 @@ import (
 	"code.gitea.io/gitea/modules/storage"
 	"code.gitea.io/gitea/modules/timeutil"
 	"code.gitea.io/gitea/modules/util"
+	"code.gitea.io/gitea/modules/validation"
 )
 
 // Attachment represent a attachment of issue/comment/release.
@@ -31,6 +32,7 @@ type Attachment struct {
 	NoAutoTime        bool               `xorm:"-"`
 	CreatedUnix       timeutil.TimeStamp `xorm:"created"`
 	CustomDownloadURL string             `xorm:"-"`
+	ExternalURL       string
 }
 
 func init() {
@@ -59,6 +61,10 @@ func (a *Attachment) RelativePath() string {
 
 // DownloadURL returns the download url of the attached file
 func (a *Attachment) DownloadURL() string {
+	if a.ExternalURL != "" {
+		return a.ExternalURL
+	}
+
 	if a.CustomDownloadURL != "" {
 		return a.CustomDownloadURL
 	}
@@ -86,6 +92,23 @@ func (err ErrAttachmentNotExist) Unwrap() error {
 	return util.ErrNotExist
 }
 
+type ErrInvalidExternalURL struct {
+	ExternalURL string
+}
+
+func IsErrInvalidExternalURL(err error) bool {
+	_, ok := err.(ErrInvalidExternalURL)
+	return ok
+}
+
+func (err ErrInvalidExternalURL) Error() string {
+	return fmt.Sprintf("invalid external URL: '%s'", err.ExternalURL)
+}
+
+func (err ErrInvalidExternalURL) Unwrap() error {
+	return util.ErrPermissionDenied
+}
+
 // GetAttachmentByID returns attachment by given id
 func GetAttachmentByID(ctx context.Context, id int64) (*Attachment, error) {
 	attach := &Attachment{}
@@ -221,12 +244,18 @@ func UpdateAttachmentByUUID(ctx context.Context, attach *Attachment, cols ...str
 	if attach.UUID == "" {
 		return fmt.Errorf("attachment uuid should be not blank")
 	}
+	if attach.ExternalURL != "" && !validation.IsValidExternalURL(attach.ExternalURL) {
+		return ErrInvalidExternalURL{ExternalURL: attach.ExternalURL}
+	}
 	_, err := db.GetEngine(ctx).Where("uuid=?", attach.UUID).Cols(cols...).Update(attach)
 	return err
 }
 
 // UpdateAttachment updates the given attachment in database
 func UpdateAttachment(ctx context.Context, atta *Attachment) error {
+	if atta.ExternalURL != "" && !validation.IsValidExternalURL(atta.ExternalURL) {
+		return ErrInvalidExternalURL{ExternalURL: atta.ExternalURL}
+	}
 	sess := db.GetEngine(ctx).Cols("name", "issue_id", "release_id", "comment_id", "download_count")
 	if atta.ID != 0 && atta.UUID == "" {
 		sess = sess.ID(atta.ID)
diff --git a/modules/structs/attachment.go b/modules/structs/attachment.go
index 38beca5e99..c8a2c6634b 100644
--- a/modules/structs/attachment.go
+++ b/modules/structs/attachment.go
@@ -18,10 +18,14 @@ type Attachment struct {
 	Created     time.Time `json:"created_at"`
 	UUID        string    `json:"uuid"`
 	DownloadURL string    `json:"browser_download_url"`
+	// Enum: attachment,external
+	Type string `json:"type"`
 }
 
 // EditAttachmentOptions options for editing attachments
 // swagger:model
 type EditAttachmentOptions struct {
 	Name string `json:"name"`
+	// (Can only be set if existing attachment is of external type)
+	DownloadURL string `json:"browser_download_url"`
 }
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 0c462d03ed..3df2597886 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -2721,6 +2721,12 @@ release.add_tag = Create tag
 release.releases_for = Releases for %s
 release.tags_for = Tags for %s
 release.system_generated = This attachment is automatically generated.
+release.type_attachment = Attachment
+release.type_external_asset = External Asset
+release.asset_name = Asset Name
+release.asset_external_url = External URL
+release.add_external_asset = Add External Asset
+release.invalid_external_url = Invalid External URL: "%s"
 
 branch.name = Branch name
 branch.already_exists = A branch named "%s" already exists.
diff --git a/routers/api/v1/repo/release.go b/routers/api/v1/repo/release.go
index 1544a64273..979ab42b31 100644
--- a/routers/api/v1/repo/release.go
+++ b/routers/api/v1/repo/release.go
@@ -247,7 +247,7 @@ func CreateRelease(ctx *context.APIContext) {
 			IsTag:            false,
 			Repo:             ctx.Repo.Repository,
 		}
-		if err := release_service.CreateRelease(ctx.Repo.GitRepo, rel, nil, ""); err != nil {
+		if err := release_service.CreateRelease(ctx.Repo.GitRepo, rel, "", nil); err != nil {
 			if repo_model.IsErrReleaseAlreadyExist(err) {
 				ctx.Error(http.StatusConflict, "ReleaseAlreadyExist", err)
 			} else if models.IsErrProtectedTagName(err) {
@@ -274,7 +274,7 @@ func CreateRelease(ctx *context.APIContext) {
 		rel.Publisher = ctx.Doer
 		rel.Target = form.Target
 
-		if err = release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, nil, nil, nil, true); err != nil {
+		if err = release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, true, nil); err != nil {
 			ctx.Error(http.StatusInternalServerError, "UpdateRelease", err)
 			return
 		}
@@ -351,7 +351,7 @@ func EditRelease(ctx *context.APIContext) {
 	if form.HideArchiveLinks != nil {
 		rel.HideArchiveLinks = *form.HideArchiveLinks
 	}
-	if err := release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, nil, nil, nil, false); err != nil {
+	if err := release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, false, nil); err != nil {
 		ctx.Error(http.StatusInternalServerError, "UpdateRelease", err)
 		return
 	}
diff --git a/routers/api/v1/repo/release_attachment.go b/routers/api/v1/repo/release_attachment.go
index 59fd83e3a2..5e43f2987a 100644
--- a/routers/api/v1/repo/release_attachment.go
+++ b/routers/api/v1/repo/release_attachment.go
@@ -5,7 +5,10 @@ package repo
 
 import (
 	"io"
+	"mime/multipart"
 	"net/http"
+	"net/url"
+	"path"
 	"strings"
 
 	repo_model "code.gitea.io/gitea/models/repo"
@@ -179,11 +182,18 @@ func CreateReleaseAttachment(ctx *context.APIContext) {
 	//   description: name of the attachment
 	//   type: string
 	//   required: false
+	// # There is no good way to specify "either 'attachment' or 'external_url' is required" with OpenAPI
+	// # https://github.com/OAI/OpenAPI-Specification/issues/256
 	// - name: attachment
 	//   in: formData
-	//   description: attachment to upload
+	//   description: attachment to upload (this parameter is incompatible with `external_url`)
 	//   type: file
 	//   required: false
+	// - name: external_url
+	//   in: formData
+	//   description: url to external asset (this parameter is incompatible with `attachment`)
+	//   type: string
+	//   required: false
 	// responses:
 	//   "201":
 	//     "$ref": "#/responses/Attachment"
@@ -205,51 +215,96 @@ func CreateReleaseAttachment(ctx *context.APIContext) {
 	}
 
 	// Get uploaded file from request
-	var content io.ReadCloser
-	var filename string
-	var size int64 = -1
+	var isForm, hasAttachmentFile, hasExternalURL bool
+	externalURL := ctx.FormString("external_url")
+	hasExternalURL = externalURL != ""
+	filename := ctx.FormString("name")
+	isForm = strings.HasPrefix(strings.ToLower(ctx.Req.Header.Get("Content-Type")), "multipart/form-data")
 
-	if strings.HasPrefix(strings.ToLower(ctx.Req.Header.Get("Content-Type")), "multipart/form-data") {
-		file, header, err := ctx.Req.FormFile("attachment")
-		if err != nil {
-			ctx.Error(http.StatusInternalServerError, "GetFile", err)
-			return
-		}
-		defer file.Close()
-
-		content = file
-		size = header.Size
-		filename = header.Filename
-		if name := ctx.FormString("name"); name != "" {
-			filename = name
-		}
+	if isForm {
+		_, _, err := ctx.Req.FormFile("attachment")
+		hasAttachmentFile = err == nil
 	} else {
-		content = ctx.Req.Body
-		filename = ctx.FormString("name")
+		hasAttachmentFile = ctx.Req.Body != nil
 	}
 
-	if filename == "" {
-		ctx.Error(http.StatusBadRequest, "CreateReleaseAttachment", "Could not determine name of attachment.")
-		return
-	}
+	if hasAttachmentFile && hasExternalURL {
+		ctx.Error(http.StatusBadRequest, "DuplicateAttachment", "'attachment' and 'external_url' are mutually exclusive")
+	} else if hasAttachmentFile {
+		var content io.ReadCloser
+		var size int64 = -1
 
-	// Create a new attachment and save the file
-	attach, err := attachment.UploadAttachment(ctx, content, setting.Repository.Release.AllowedTypes, size, &repo_model.Attachment{
-		Name:       filename,
-		UploaderID: ctx.Doer.ID,
-		RepoID:     ctx.Repo.Repository.ID,
-		ReleaseID:  releaseID,
-	})
-	if err != nil {
-		if upload.IsErrFileTypeForbidden(err) {
-			ctx.Error(http.StatusBadRequest, "DetectContentType", err)
+		if isForm {
+			var header *multipart.FileHeader
+			content, header, _ = ctx.Req.FormFile("attachment")
+			size = header.Size
+			defer content.Close()
+			if filename == "" {
+				filename = header.Filename
+			}
+		} else {
+			content = ctx.Req.Body
+			defer content.Close()
+		}
+
+		if filename == "" {
+			ctx.Error(http.StatusBadRequest, "MissingName", "Missing 'name' parameter")
 			return
 		}
-		ctx.Error(http.StatusInternalServerError, "NewAttachment", err)
-		return
-	}
 
-	ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach))
+		// Create a new attachment and save the file
+		attach, err := attachment.UploadAttachment(ctx, content, setting.Repository.Release.AllowedTypes, size, &repo_model.Attachment{
+			Name:       filename,
+			UploaderID: ctx.Doer.ID,
+			RepoID:     ctx.Repo.Repository.ID,
+			ReleaseID:  releaseID,
+		})
+		if err != nil {
+			if upload.IsErrFileTypeForbidden(err) {
+				ctx.Error(http.StatusBadRequest, "DetectContentType", err)
+				return
+			}
+			ctx.Error(http.StatusInternalServerError, "NewAttachment", err)
+			return
+		}
+
+		ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach))
+	} else if hasExternalURL {
+		url, err := url.Parse(externalURL)
+		if err != nil {
+			ctx.Error(http.StatusBadRequest, "InvalidExternalURL", err)
+			return
+		}
+
+		if filename == "" {
+			filename = path.Base(url.Path)
+
+			if filename == "." {
+				// Url path is empty
+				filename = url.Host
+			}
+		}
+
+		attach, err := attachment.NewExternalAttachment(ctx, &repo_model.Attachment{
+			Name:        filename,
+			UploaderID:  ctx.Doer.ID,
+			RepoID:      ctx.Repo.Repository.ID,
+			ReleaseID:   releaseID,
+			ExternalURL: url.String(),
+		})
+		if err != nil {
+			if repo_model.IsErrInvalidExternalURL(err) {
+				ctx.Error(http.StatusBadRequest, "NewExternalAttachment", err)
+			} else {
+				ctx.Error(http.StatusInternalServerError, "NewExternalAttachment", err)
+			}
+			return
+		}
+
+		ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach))
+	} else {
+		ctx.Error(http.StatusBadRequest, "MissingAttachment", "One of 'attachment' or 'external_url' is required")
+	}
 }
 
 // EditReleaseAttachment updates the given attachment
@@ -322,8 +377,21 @@ func EditReleaseAttachment(ctx *context.APIContext) {
 		attach.Name = form.Name
 	}
 
+	if form.DownloadURL != "" {
+		if attach.ExternalURL == "" {
+			ctx.Error(http.StatusBadRequest, "EditAttachment", "existing attachment is not external")
+			return
+		}
+		attach.ExternalURL = form.DownloadURL
+	}
+
 	if err := repo_model.UpdateAttachment(ctx, attach); err != nil {
-		ctx.Error(http.StatusInternalServerError, "UpdateAttachment", attach)
+		if repo_model.IsErrInvalidExternalURL(err) {
+			ctx.Error(http.StatusBadRequest, "UpdateAttachment", err)
+		} else {
+			ctx.Error(http.StatusInternalServerError, "UpdateAttachment", err)
+		}
+		return
 	}
 	ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach))
 }
diff --git a/routers/web/repo/attachment.go b/routers/web/repo/attachment.go
index f0c5622aec..b42effd8c3 100644
--- a/routers/web/repo/attachment.go
+++ b/routers/web/repo/attachment.go
@@ -122,6 +122,11 @@ func ServeAttachment(ctx *context.Context, uuid string) {
 		}
 	}
 
+	if attach.ExternalURL != "" {
+		ctx.Redirect(attach.ExternalURL)
+		return
+	}
+
 	if err := attach.IncreaseDownloadCount(ctx); err != nil {
 		ctx.ServerError("IncreaseDownloadCount", err)
 		return
diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go
index 3927e3d2d9..2266debd6e 100644
--- a/routers/web/repo/release.go
+++ b/routers/web/repo/release.go
@@ -18,6 +18,7 @@ import (
 	"code.gitea.io/gitea/models/unit"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/base"
+	"code.gitea.io/gitea/modules/container"
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/gitrepo"
 	"code.gitea.io/gitea/modules/log"
@@ -491,9 +492,44 @@ func NewReleasePost(ctx *context.Context) {
 		return
 	}
 
-	var attachmentUUIDs []string
+	attachmentChanges := make(container.Set[*releaseservice.AttachmentChange])
+	attachmentChangesByID := make(map[string]*releaseservice.AttachmentChange)
+
 	if setting.Attachment.Enabled {
-		attachmentUUIDs = form.Files
+		for _, uuid := range form.Files {
+			attachmentChanges.Add(&releaseservice.AttachmentChange{
+				Action: "add",
+				Type:   "attachment",
+				UUID:   uuid,
+			})
+		}
+
+		const namePrefix = "attachment-new-name-"
+		const exturlPrefix = "attachment-new-exturl-"
+		for k, v := range ctx.Req.Form {
+			isNewName := strings.HasPrefix(k, namePrefix)
+			isNewExturl := strings.HasPrefix(k, exturlPrefix)
+			if isNewName || isNewExturl {
+				var id string
+				if isNewName {
+					id = k[len(namePrefix):]
+				} else if isNewExturl {
+					id = k[len(exturlPrefix):]
+				}
+				if _, ok := attachmentChangesByID[id]; !ok {
+					attachmentChangesByID[id] = &releaseservice.AttachmentChange{
+						Action: "add",
+						Type:   "external",
+					}
+					attachmentChanges.Add(attachmentChangesByID[id])
+				}
+				if isNewName {
+					attachmentChangesByID[id].Name = v[0]
+				} else if isNewExturl {
+					attachmentChangesByID[id].ExternalURL = v[0]
+				}
+			}
+		}
 	}
 
 	rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, form.TagName)
@@ -553,7 +589,7 @@ func NewReleasePost(ctx *context.Context) {
 			IsTag:            false,
 		}
 
-		if err = releaseservice.CreateRelease(ctx.Repo.GitRepo, rel, attachmentUUIDs, msg); err != nil {
+		if err = releaseservice.CreateRelease(ctx.Repo.GitRepo, rel, msg, attachmentChanges.Values()); err != nil {
 			ctx.Data["Err_TagName"] = true
 			switch {
 			case repo_model.IsErrReleaseAlreadyExist(err):
@@ -562,6 +598,8 @@ func NewReleasePost(ctx *context.Context) {
 				ctx.RenderWithErr(ctx.Tr("repo.release.tag_name_invalid"), tplReleaseNew, &form)
 			case models.IsErrProtectedTagName(err):
 				ctx.RenderWithErr(ctx.Tr("repo.release.tag_name_protected"), tplReleaseNew, &form)
+			case repo_model.IsErrInvalidExternalURL(err):
+				ctx.RenderWithErr(ctx.Tr("repo.release.invalid_external_url", err.(repo_model.ErrInvalidExternalURL).ExternalURL), tplReleaseNew, &form)
 			default:
 				ctx.ServerError("CreateRelease", err)
 			}
@@ -583,9 +621,14 @@ func NewReleasePost(ctx *context.Context) {
 		rel.HideArchiveLinks = form.HideArchiveLinks
 		rel.IsTag = false
 
-		if err = releaseservice.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, attachmentUUIDs, nil, nil, true); err != nil {
+		if err = releaseservice.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, true, attachmentChanges.Values()); err != nil {
 			ctx.Data["Err_TagName"] = true
-			ctx.ServerError("UpdateRelease", err)
+			switch {
+			case repo_model.IsErrInvalidExternalURL(err):
+				ctx.RenderWithErr(ctx.Tr("repo.release.invalid_external_url", err.(repo_model.ErrInvalidExternalURL).ExternalURL), tplReleaseNew, &form)
+			default:
+				ctx.ServerError("UpdateRelease", err)
+			}
 			return
 		}
 	}
@@ -667,6 +710,15 @@ func EditReleasePost(ctx *context.Context) {
 	ctx.Data["prerelease"] = rel.IsPrerelease
 	ctx.Data["hide_archive_links"] = rel.HideArchiveLinks
 
+	rel.Repo = ctx.Repo.Repository
+	if err := rel.LoadAttributes(ctx); err != nil {
+		ctx.ServerError("LoadAttributes", err)
+		return
+	}
+	// TODO: If an error occurs, do not forget the attachment edits the user made
+	// when displaying the error message.
+	ctx.Data["attachments"] = rel.Attachments
+
 	if ctx.HasError() {
 		ctx.HTML(http.StatusOK, tplReleaseNew)
 		return
@@ -674,15 +726,67 @@ func EditReleasePost(ctx *context.Context) {
 
 	const delPrefix = "attachment-del-"
 	const editPrefix = "attachment-edit-"
-	var addAttachmentUUIDs, delAttachmentUUIDs []string
-	editAttachments := make(map[string]string) // uuid -> new name
+	const newPrefix = "attachment-new-"
+	const namePrefix = "name-"
+	const exturlPrefix = "exturl-"
+	attachmentChanges := make(container.Set[*releaseservice.AttachmentChange])
+	attachmentChangesByID := make(map[string]*releaseservice.AttachmentChange)
+
 	if setting.Attachment.Enabled {
-		addAttachmentUUIDs = form.Files
+		for _, uuid := range form.Files {
+			attachmentChanges.Add(&releaseservice.AttachmentChange{
+				Action: "add",
+				Type:   "attachment",
+				UUID:   uuid,
+			})
+		}
+
 		for k, v := range ctx.Req.Form {
 			if strings.HasPrefix(k, delPrefix) && v[0] == "true" {
-				delAttachmentUUIDs = append(delAttachmentUUIDs, k[len(delPrefix):])
-			} else if strings.HasPrefix(k, editPrefix) {
-				editAttachments[k[len(editPrefix):]] = v[0]
+				attachmentChanges.Add(&releaseservice.AttachmentChange{
+					Action: "delete",
+					UUID:   k[len(delPrefix):],
+				})
+			} else {
+				isUpdatedName := strings.HasPrefix(k, editPrefix+namePrefix)
+				isUpdatedExturl := strings.HasPrefix(k, editPrefix+exturlPrefix)
+				isNewName := strings.HasPrefix(k, newPrefix+namePrefix)
+				isNewExturl := strings.HasPrefix(k, newPrefix+exturlPrefix)
+
+				if isUpdatedName || isUpdatedExturl || isNewName || isNewExturl {
+					var uuid string
+
+					if isUpdatedName {
+						uuid = k[len(editPrefix+namePrefix):]
+					} else if isUpdatedExturl {
+						uuid = k[len(editPrefix+exturlPrefix):]
+					} else if isNewName {
+						uuid = k[len(newPrefix+namePrefix):]
+					} else if isNewExturl {
+						uuid = k[len(newPrefix+exturlPrefix):]
+					}
+
+					if _, ok := attachmentChangesByID[uuid]; !ok {
+						attachmentChangesByID[uuid] = &releaseservice.AttachmentChange{
+							Type: "attachment",
+							UUID: uuid,
+						}
+						attachmentChanges.Add(attachmentChangesByID[uuid])
+					}
+
+					if isUpdatedName || isUpdatedExturl {
+						attachmentChangesByID[uuid].Action = "update"
+					} else if isNewName || isNewExturl {
+						attachmentChangesByID[uuid].Action = "add"
+					}
+
+					if isUpdatedName || isNewName {
+						attachmentChangesByID[uuid].Name = v[0]
+					} else if isUpdatedExturl || isNewExturl {
+						attachmentChangesByID[uuid].ExternalURL = v[0]
+						attachmentChangesByID[uuid].Type = "external"
+					}
+				}
 			}
 		}
 	}
@@ -692,9 +796,13 @@ func EditReleasePost(ctx *context.Context) {
 	rel.IsDraft = len(form.Draft) > 0
 	rel.IsPrerelease = form.Prerelease
 	rel.HideArchiveLinks = form.HideArchiveLinks
-	if err = releaseservice.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo,
-		rel, addAttachmentUUIDs, delAttachmentUUIDs, editAttachments, false); err != nil {
-		ctx.ServerError("UpdateRelease", err)
+	if err = releaseservice.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, false, attachmentChanges.Values()); err != nil {
+		switch {
+		case repo_model.IsErrInvalidExternalURL(err):
+			ctx.RenderWithErr(ctx.Tr("repo.release.invalid_external_url", err.(repo_model.ErrInvalidExternalURL).ExternalURL), tplReleaseNew, &form)
+		default:
+			ctx.ServerError("UpdateRelease", err)
+		}
 		return
 	}
 	ctx.Redirect(ctx.Repo.RepoLink + "/releases")
diff --git a/services/attachment/attachment.go b/services/attachment/attachment.go
index 4481966b4a..c911945e5d 100644
--- a/services/attachment/attachment.go
+++ b/services/attachment/attachment.go
@@ -13,6 +13,7 @@ import (
 	repo_model "code.gitea.io/gitea/models/repo"
 	"code.gitea.io/gitea/modules/storage"
 	"code.gitea.io/gitea/modules/util"
+	"code.gitea.io/gitea/modules/validation"
 	"code.gitea.io/gitea/services/context/upload"
 
 	"github.com/google/uuid"
@@ -43,6 +44,28 @@ func NewAttachment(ctx context.Context, attach *repo_model.Attachment, file io.R
 	return attach, err
 }
 
+func NewExternalAttachment(ctx context.Context, attach *repo_model.Attachment) (*repo_model.Attachment, error) {
+	if attach.RepoID == 0 {
+		return nil, fmt.Errorf("attachment %s should belong to a repository", attach.Name)
+	}
+	if attach.ExternalURL == "" {
+		return nil, fmt.Errorf("attachment %s should have a external url", attach.Name)
+	}
+	if !validation.IsValidExternalURL(attach.ExternalURL) {
+		return nil, repo_model.ErrInvalidExternalURL{ExternalURL: attach.ExternalURL}
+	}
+
+	attach.UUID = uuid.New().String()
+
+	eng := db.GetEngine(ctx)
+	if attach.NoAutoTime {
+		eng.NoAutoTime()
+	}
+	_, err := eng.Insert(attach)
+
+	return attach, err
+}
+
 // UploadAttachment upload new attachment into storage and update database
 func UploadAttachment(ctx context.Context, file io.Reader, allowedTypes string, fileSize int64, attach *repo_model.Attachment) (*repo_model.Attachment, error) {
 	buf := make([]byte, 1024)
diff --git a/services/convert/attachment.go b/services/convert/attachment.go
index 4a8f10f7b0..d632c94c18 100644
--- a/services/convert/attachment.go
+++ b/services/convert/attachment.go
@@ -9,6 +9,10 @@ import (
 )
 
 func WebAssetDownloadURL(repo *repo_model.Repository, attach *repo_model.Attachment) string {
+	if attach.ExternalURL != "" {
+		return attach.ExternalURL
+	}
+
 	return attach.DownloadURL()
 }
 
@@ -28,6 +32,12 @@ func ToAPIAttachment(repo *repo_model.Repository, a *repo_model.Attachment) *api
 
 // toAttachment converts models.Attachment to api.Attachment for API usage
 func toAttachment(repo *repo_model.Repository, a *repo_model.Attachment, getDownloadURL func(repo *repo_model.Repository, attach *repo_model.Attachment) string) *api.Attachment {
+	var typeName string
+	if a.ExternalURL != "" {
+		typeName = "external"
+	} else {
+		typeName = "attachment"
+	}
 	return &api.Attachment{
 		ID:            a.ID,
 		Name:          a.Name,
@@ -36,6 +46,7 @@ func toAttachment(repo *repo_model.Repository, a *repo_model.Attachment, getDown
 		Size:          a.Size,
 		UUID:          a.UUID,
 		DownloadURL:   getDownloadURL(repo, a), // for web request json and api request json, return different download urls
+		Type:          typeName,
 	}
 }
 
diff --git a/services/f3/driver/release.go b/services/f3/driver/release.go
index d0672b8965..fab9222c11 100644
--- a/services/f3/driver/release.go
+++ b/services/f3/driver/release.go
@@ -129,7 +129,7 @@ func (o *release) Put(ctx context.Context) generic.NodeID {
 		panic(err)
 	}
 	defer gitRepo.Close()
-	if err := release_service.CreateRelease(gitRepo, o.forgejoRelease, nil, ""); err != nil {
+	if err := release_service.CreateRelease(gitRepo, o.forgejoRelease, "", nil); err != nil {
 		panic(err)
 	}
 	o.Trace("release created %d", o.forgejoRelease.ID)
diff --git a/services/release/release.go b/services/release/release.go
index 5062af1436..11740e4cc8 100644
--- a/services/release/release.go
+++ b/services/release/release.go
@@ -23,9 +23,18 @@ import (
 	"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 {
@@ -128,7 +137,7 @@ func createTag(ctx context.Context, gitRepo *git.Repository, rel *repo_model.Rel
 }
 
 // CreateRelease creates a new release of repository.
-func CreateRelease(gitRepo *git.Repository, rel *repo_model.Release, attachmentUUIDs []string, msg string) error {
+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
@@ -147,7 +156,42 @@ func CreateRelease(gitRepo *git.Repository, rel *repo_model.Release, attachmentU
 		return err
 	}
 
-	if err = repo_model.AddReleaseAttachments(gitRepo.Ctx, rel.ID, attachmentUUIDs); err != nil {
+	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
 	}
 
@@ -198,8 +242,7 @@ func CreateNewTag(ctx context.Context, doer *user_model.User, repo *repo_model.R
 // 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,
-	addAttachmentUUIDs, delAttachmentUUIDs []string, editAttachments map[string]string, createdFromTag bool,
+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")
@@ -220,14 +263,64 @@ func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repo
 		return err
 	}
 
-	if err = repo_model.AddReleaseAttachments(ctx, rel.ID, addAttachmentUUIDs); err != nil {
+	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)
+		attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, delAttachmentUUIDs.Values())
 		if err != nil {
 			return fmt.Errorf("GetAttachmentsByUUIDs [uuids: %v]: %w", delAttachmentUUIDs, err)
 		}
@@ -246,15 +339,11 @@ func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repo
 		}
 	}
 
-	if len(editAttachments) > 0 {
-		updateAttachmentsList := make([]string, 0, len(editAttachments))
-		for k := range editAttachments {
-			updateAttachmentsList = append(updateAttachmentsList, k)
-		}
+	if len(updateAttachmentUUIDs) > 0 {
 		// Check attachments
-		attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, updateAttachmentsList)
+		attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, updateAttachmentUUIDs.Values())
 		if err != nil {
-			return fmt.Errorf("GetAttachmentsByUUIDs [uuids: %v]: %w", updateAttachmentsList, err)
+			return fmt.Errorf("GetAttachmentsByUUIDs [uuids: %v]: %w", updateAttachmentUUIDs, err)
 		}
 		for _, attach := range attachments {
 			if attach.ReleaseID != rel.ID {
@@ -264,15 +353,16 @@ func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repo
 				}
 			}
 		}
+	}
 
-		for uuid, newName := range editAttachments {
-			if !deletedUUIDs.Contains(uuid) {
-				if err = repo_model.UpdateAttachmentByUUID(ctx, &repo_model.Attachment{
-					UUID: uuid,
-					Name: newName,
-				}, "name"); err != nil {
-					return err
-				}
+	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
 			}
 		}
 	}
@@ -281,7 +371,7 @@ func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repo
 		return err
 	}
 
-	for _, uuid := range delAttachmentUUIDs {
+	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.
diff --git a/services/release/release_test.go b/services/release/release_test.go
index eac1879f87..cf4421a17d 100644
--- a/services/release/release_test.go
+++ b/services/release/release_test.go
@@ -47,7 +47,7 @@ func TestRelease_Create(t *testing.T) {
 		IsDraft:      false,
 		IsPrerelease: false,
 		IsTag:        false,
-	}, nil, ""))
+	}, "", []*AttachmentChange{}))
 
 	assert.NoError(t, CreateRelease(gitRepo, &repo_model.Release{
 		RepoID:       repo.ID,
@@ -61,7 +61,7 @@ func TestRelease_Create(t *testing.T) {
 		IsDraft:      false,
 		IsPrerelease: false,
 		IsTag:        false,
-	}, nil, ""))
+	}, "", []*AttachmentChange{}))
 
 	assert.NoError(t, CreateRelease(gitRepo, &repo_model.Release{
 		RepoID:       repo.ID,
@@ -75,7 +75,7 @@ func TestRelease_Create(t *testing.T) {
 		IsDraft:      false,
 		IsPrerelease: false,
 		IsTag:        false,
-	}, nil, ""))
+	}, "", []*AttachmentChange{}))
 
 	assert.NoError(t, CreateRelease(gitRepo, &repo_model.Release{
 		RepoID:       repo.ID,
@@ -89,7 +89,7 @@ func TestRelease_Create(t *testing.T) {
 		IsDraft:      true,
 		IsPrerelease: false,
 		IsTag:        false,
-	}, nil, ""))
+	}, "", []*AttachmentChange{}))
 
 	assert.NoError(t, CreateRelease(gitRepo, &repo_model.Release{
 		RepoID:       repo.ID,
@@ -103,7 +103,7 @@ func TestRelease_Create(t *testing.T) {
 		IsDraft:      false,
 		IsPrerelease: true,
 		IsTag:        false,
-	}, nil, ""))
+	}, "", []*AttachmentChange{}))
 
 	testPlayload := "testtest"
 
@@ -127,7 +127,67 @@ func TestRelease_Create(t *testing.T) {
 		IsPrerelease: false,
 		IsTag:        true,
 	}
-	assert.NoError(t, CreateRelease(gitRepo, &release, []string{attach.UUID}, "test"))
+	assert.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) {
@@ -153,13 +213,13 @@ func TestRelease_Update(t *testing.T) {
 		IsDraft:      false,
 		IsPrerelease: false,
 		IsTag:        false,
-	}, nil, ""))
+	}, "", []*AttachmentChange{}))
 	release, err := repo_model.GetRelease(db.DefaultContext, repo.ID, "v1.1.1")
 	assert.NoError(t, err)
 	releaseCreatedUnix := release.CreatedUnix
 	time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp
 	release.Note = "Changed note"
-	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, nil, nil, false))
+	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{}))
 	release, err = repo_model.GetReleaseByID(db.DefaultContext, release.ID)
 	assert.NoError(t, err)
 	assert.Equal(t, int64(releaseCreatedUnix), int64(release.CreatedUnix))
@@ -177,13 +237,13 @@ func TestRelease_Update(t *testing.T) {
 		IsDraft:      true,
 		IsPrerelease: false,
 		IsTag:        false,
-	}, nil, ""))
+	}, "", []*AttachmentChange{}))
 	release, err = repo_model.GetRelease(db.DefaultContext, repo.ID, "v1.2.1")
 	assert.NoError(t, err)
 	releaseCreatedUnix = release.CreatedUnix
 	time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp
 	release.Title = "Changed title"
-	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, nil, nil, false))
+	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{}))
 	release, err = repo_model.GetReleaseByID(db.DefaultContext, release.ID)
 	assert.NoError(t, err)
 	assert.Less(t, int64(releaseCreatedUnix), int64(release.CreatedUnix))
@@ -201,14 +261,14 @@ func TestRelease_Update(t *testing.T) {
 		IsDraft:      false,
 		IsPrerelease: true,
 		IsTag:        false,
-	}, nil, ""))
+	}, "", []*AttachmentChange{}))
 	release, err = repo_model.GetRelease(db.DefaultContext, repo.ID, "v1.3.1")
 	assert.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"
-	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, nil, nil, false))
+	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{}))
 	release, err = repo_model.GetReleaseByID(db.DefaultContext, release.ID)
 	assert.NoError(t, err)
 	assert.Equal(t, int64(releaseCreatedUnix), int64(release.CreatedUnix))
@@ -227,13 +287,13 @@ func TestRelease_Update(t *testing.T) {
 		IsPrerelease: false,
 		IsTag:        false,
 	}
-	assert.NoError(t, CreateRelease(gitRepo, release, nil, ""))
+	assert.NoError(t, CreateRelease(gitRepo, release, "", []*AttachmentChange{}))
 	assert.Greater(t, release.ID, int64(0))
 
 	release.IsDraft = false
 	tagName := release.TagName
 
-	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, nil, nil, false))
+	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{}))
 	release, err = repo_model.GetReleaseByID(db.DefaultContext, release.ID)
 	assert.NoError(t, err)
 	assert.Equal(t, tagName, release.TagName)
@@ -247,29 +307,79 @@ func TestRelease_Update(t *testing.T) {
 	}, strings.NewReader(samplePayload), int64(len([]byte(samplePayload))))
 	assert.NoError(t, err)
 
-	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, []string{attach.UUID}, nil, nil, false))
+	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*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, 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
-	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, nil, map[string]string{
-		attach.UUID: "test2.txt",
-	}, false))
+	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{
+		{
+			Action: "update",
+			Name:   "test2.txt",
+			UUID:   attach.UUID,
+		},
+	}))
 	release.Attachments = nil
 	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, 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
-	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, []string{attach.UUID}, nil, false))
+	assert.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) {
diff --git a/templates/repo/release/list.tmpl b/templates/repo/release/list.tmpl
index bcb71e5f60..cc5c6702f3 100644
--- a/templates/repo/release/list.tmpl
+++ b/templates/repo/release/list.tmpl
@@ -72,7 +72,9 @@
 								<ul class="list">
 									{{if $hasArchiveLinks}}
 										<li>
-											<a class="archive-link tw-flex-1" href="{{$.RepoLink}}/archive/{{$release.TagName | PathEscapeSegments}}.zip" rel="nofollow"><strong>{{svg "octicon-file-zip" 16 "tw-mr-1"}}{{ctx.Locale.Tr "repo.release.source_code"}} (ZIP)</strong></a>
+											<a class="archive-link tw-flex-1 flex-text-inline tw-font-bold" href="{{$.RepoLink}}/archive/{{$release.TagName | PathEscapeSegments}}.zip" rel="nofollow">
+												{{svg "octicon-file-zip" 16 "tw-mr-1"}}{{ctx.Locale.Tr "repo.release.source_code"}} (ZIP)
+											</a>
 											<div class="tw-mr-1">
 												<span class="text grey">{{ctx.Locale.TrN .Release.ArchiveDownloadCount.Zip "repo.release.download_count_one" "repo.release.download_count_few" (ctx.Locale.PrettyNumber .Release.ArchiveDownloadCount.Zip)}}</span>
 											</div>
@@ -81,7 +83,9 @@
 											</span>
 										</li>
 										<li class="{{if $hasReleaseAttachment}}start-gap{{end}}">
-											<a class="archive-link tw-flex-1" href="{{$.RepoLink}}/archive/{{$release.TagName | PathEscapeSegments}}.tar.gz" rel="nofollow"><strong>{{svg "octicon-file-zip" 16 "tw-mr-1"}}{{ctx.Locale.Tr "repo.release.source_code"}} (TAR.GZ)</strong></a>
+											<a class="archive-link tw-flex-1 flex-text-inline tw-font-bold" href="{{$.RepoLink}}/archive/{{$release.TagName | PathEscapeSegments}}.tar.gz" rel="nofollow">
+												{{svg "octicon-file-zip" 16 "tw-mr-1"}}{{ctx.Locale.Tr "repo.release.source_code"}} (ZIP)
+											</a>
 											<div class="tw-mr-1">
 												<span class="text grey">{{ctx.Locale.TrN .Release.ArchiveDownloadCount.TarGz "repo.release.download_count_one" "repo.release.download_count_few" (ctx.Locale.PrettyNumber .Release.ArchiveDownloadCount.TarGz)}}</span>
 											</div>
@@ -92,14 +96,22 @@
 										{{if $hasReleaseAttachment}}<hr>{{end}}
 									{{end}}
 									{{range $release.Attachments}}
-										<li>
-											<a target="_blank" rel="nofollow" href="{{.DownloadURL}}" download>
-												<strong>{{svg "octicon-package" 16 "tw-mr-1"}}{{.Name}}</strong>
-											</a>
-											<div>
-												<span class="text grey">{{ctx.Locale.TrN .DownloadCount "repo.release.download_count_one" "repo.release.download_count_few" (ctx.Locale.PrettyNumber .DownloadCount)}} · {{.Size | ctx.Locale.TrSize}}</span>
-											</div>
-										</li>
+										{{if .ExternalURL}}
+											<li>
+												<a class="tw-flex-1 flex-text-inline tw-font-bold" target="_blank" rel="nofollow" href="{{.DownloadURL}}" download>
+													{{svg "octicon-link-external" 16 "tw-mr-1"}}{{.Name}}
+												</a>
+											</li>
+										{{else}}
+											<li>
+												<a class="tw-flex-1 flex-text-inline tw-font-bold" target="_blank" rel="nofollow" href="{{.DownloadURL}}" download>
+													{{svg "octicon-package" 16 "tw-mr-1"}}{{.Name}}
+												</a>
+												<div>
+													<span class="text grey">{{ctx.Locale.TrN .DownloadCount "repo.release.download_count_one" "repo.release.download_count_few" (ctx.Locale.PrettyNumber .DownloadCount)}} · {{.Size | ctx.Locale.TrSize}}</span>
+												</div>
+											</li>
+										{{end}}
 									{{end}}
 								</ul>
 							</details>
diff --git a/templates/repo/release/new.tmpl b/templates/repo/release/new.tmpl
index b653fc0c2c..9278e7c28b 100644
--- a/templates/repo/release/new.tmpl
+++ b/templates/repo/release/new.tmpl
@@ -63,15 +63,45 @@
 				{{range .attachments}}
 					<div class="field flex-text-block" id="attachment-{{.ID}}">
 						<div class="flex-text-inline tw-flex-1">
-							<input name="attachment-edit-{{.UUID}}"  class="attachment_edit" required value="{{.Name}}">
-							<input name="attachment-del-{{.UUID}}" type="hidden" value="false">
-							<span class="ui text grey tw-whitespace-nowrap">{{ctx.Locale.TrN .DownloadCount "repo.release.download_count_one" "repo.release.download_count_few" (ctx.Locale.PrettyNumber .DownloadCount)}} · {{.Size | ctx.Locale.TrSize}}</span>
+							<div class="flex-text-inline tw-shrink-0" title="{{ctx.Locale.Tr "repo.release.type_attachment"}}">
+								{{if .ExternalURL}}
+									{{svg "octicon-link-external" 16 "tw-mr-2"}}
+								{{else}}
+									{{svg "octicon-package" 16 "tw-mr-2"}}
+								{{end}}
+							</div>
+							<input name="attachment-edit-name-{{.UUID}}" placeholder="{{ctx.Locale.Tr "repo.release.asset_name"}}" class="attachment_edit" required value="{{.Name}}">
+							<input name="attachment-del-{{.UUID}}" type="hidden"
+							value="false">
+							{{if .ExternalURL}}
+								<input name="attachment-edit-exturl-{{.UUID}}" placeholder="{{ctx.Locale.Tr "repo.release.asset_external_url"}}" class="attachment_edit" required value="{{.ExternalURL}}">
+							{{else}}
+								<span class="ui text grey tw-whitespace-nowrap tw-ml-auto tw-pl-3">{{ctx.Locale.TrN
+								.DownloadCount "repo.release.download_count_one"
+								"repo.release.download_count_few" (ctx.Locale.PrettyNumber
+								.DownloadCount)}} · {{.Size | ctx.Locale.TrSize}}</span>
+							{{end}}
 						</div>
-						<a class="ui mini compact red button remove-rel-attach" data-id="{{.ID}}" data-uuid="{{.UUID}}">
+						<a class="ui mini red button remove-rel-attach tw-ml-3" data-id="{{.ID}}" data-uuid="{{.UUID}}">
 							{{ctx.Locale.Tr "remove"}}
 						</a>
 					</div>
 				{{end}}
+				<div class="field flex-text-block tw-hidden" id="attachment-template">
+					<div class="flex-text-inline tw-flex-1">
+						<div class="flex-text-inline tw-shrink-0" title="{{ctx.Locale.Tr "repo.release.type_external_asset"}}">
+							{{svg "octicon-link-external" 16 "tw-mr-2"}}
+						</div>
+						<input name="attachment-template-new-name" placeholder="{{ctx.Locale.Tr "repo.release.asset_name"}}" class="attachment_edit">
+						<input name="attachment-template-new-exturl" placeholder="{{ctx.Locale.Tr "repo.release.asset_external_url"}}" class="attachment_edit">
+					</div>
+					<a class="ui mini red button remove-rel-attach tw-ml-3">
+						{{ctx.Locale.Tr "remove"}}
+					</a>
+				</div>
+				<a class="ui mini button tw-float-right tw-mb-4 tw-mt-2" id="add-external-link">
+					{{ctx.Locale.Tr "repo.release.add_external_asset"}}
+				</a>
 				{{if .IsAttachmentEnabled}}
 					<div class="field">
 						{{template "repo/upload" .}}
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index b8b896a00c..156c48d951 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -13632,9 +13632,15 @@
           },
           {
             "type": "file",
-            "description": "attachment to upload",
+            "description": "attachment to upload (this parameter is incompatible with `external_url`)",
             "name": "attachment",
             "in": "formData"
+          },
+          {
+            "type": "string",
+            "description": "url to external asset (this parameter is incompatible with `attachment`)",
+            "name": "external_url",
+            "in": "formData"
           }
         ],
         "responses": {
@@ -19010,6 +19016,14 @@
           "format": "int64",
           "x-go-name": "Size"
         },
+        "type": {
+          "type": "string",
+          "enum": [
+            "attachment",
+            "external"
+          ],
+          "x-go-name": "Type"
+        },
         "uuid": {
           "type": "string",
           "x-go-name": "UUID"
@@ -20988,6 +21002,11 @@
       "description": "EditAttachmentOptions options for editing attachments",
       "type": "object",
       "properties": {
+        "browser_download_url": {
+          "description": "(Can only be set if existing attachment is of external type)",
+          "type": "string",
+          "x-go-name": "DownloadURL"
+        },
         "name": {
           "type": "string",
           "x-go-name": "Name"
diff --git a/tests/e2e/release.test.e2e.js b/tests/e2e/release.test.e2e.js
new file mode 100644
index 0000000000..7e08a30fbe
--- /dev/null
+++ b/tests/e2e/release.test.e2e.js
@@ -0,0 +1,67 @@
+// @ts-check
+import {test, expect} from '@playwright/test';
+import {login_user, save_visual, load_logged_in_context} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+  await login_user(browser, workerInfo, 'user2');
+});
+
+test.describe.configure({
+  timeout: 30000,
+});
+
+test('External Release Attachments', async ({browser, isMobile}, workerInfo) => {
+  test.skip(isMobile);
+
+  const context = await load_logged_in_context(browser, workerInfo, 'user2');
+  /** @type {import('@playwright/test').Page} */
+  const page = await context.newPage();
+
+  // Click "New Release"
+  await page.goto('/user2/repo2/releases');
+  await page.click('.button.small.primary');
+
+  // Fill out form and create new release
+  await page.fill('input[name=tag_name]', '2.0');
+  await page.fill('input[name=title]', '2.0');
+  await page.click('#add-external-link');
+  await page.click('#add-external-link');
+  await page.fill('input[name=attachment-new-name-2]', 'Test');
+  await page.fill('input[name=attachment-new-exturl-2]', 'https://forgejo.org/');
+  await page.click('.remove-rel-attach');
+  save_visual(page);
+  await page.click('.button.small.primary');
+
+  // Validate release page and click edit
+  await expect(page.locator('.download[open] li')).toHaveCount(3);
+  await expect(page.locator('.download[open] li:nth-of-type(3)')).toContainText('Test');
+  await expect(page.locator('.download[open] li:nth-of-type(3) a')).toHaveAttribute('href', 'https://forgejo.org/');
+  save_visual(page);
+  await page.locator('.octicon-pencil').first().click();
+
+  // Validate edit page and edit the release
+  await expect(page.locator('.attachment_edit:visible')).toHaveCount(2);
+  await expect(page.locator('.attachment_edit:visible').nth(0)).toHaveValue('Test');
+  await expect(page.locator('.attachment_edit:visible').nth(1)).toHaveValue('https://forgejo.org/');
+  await page.locator('.attachment_edit:visible').nth(0).fill('Test2');
+  await page.locator('.attachment_edit:visible').nth(1).fill('https://gitea.io/');
+  await page.click('#add-external-link');
+  await expect(page.locator('.attachment_edit:visible')).toHaveCount(4);
+  await page.locator('.attachment_edit:visible').nth(2).fill('Test3');
+  await page.locator('.attachment_edit:visible').nth(3).fill('https://gitea.com/');
+  save_visual(page);
+  await page.click('.button.small.primary');
+
+  // Validate release page and click edit
+  await expect(page.locator('.download[open] li')).toHaveCount(4);
+  await expect(page.locator('.download[open] li:nth-of-type(3)')).toContainText('Test2');
+  await expect(page.locator('.download[open] li:nth-of-type(3) a')).toHaveAttribute('href', 'https://gitea.io/');
+  await expect(page.locator('.download[open] li:nth-of-type(4)')).toContainText('Test3');
+  await expect(page.locator('.download[open] li:nth-of-type(4) a')).toHaveAttribute('href', 'https://gitea.com/');
+  save_visual(page);
+  await page.locator('.octicon-pencil').first().click();
+
+  // Delete release
+  await page.click('.delete-button');
+  await page.click('.button.ok');
+});
diff --git a/tests/integration/api_releases_test.go b/tests/integration/api_releases_test.go
index c49e6ef92e..a5e769e39f 100644
--- a/tests/integration/api_releases_test.go
+++ b/tests/integration/api_releases_test.go
@@ -347,6 +347,7 @@ func TestAPIUploadAssetRelease(t *testing.T) {
 
 		assert.EqualValues(t, "stream.bin", attachment.Name)
 		assert.EqualValues(t, 104, attachment.Size)
+		assert.EqualValues(t, "attachment", attachment.Type)
 	})
 }
 
@@ -385,3 +386,69 @@ func TestAPIGetReleaseArchiveDownloadCount(t *testing.T) {
 	assert.Equal(t, int64(1), release.ArchiveDownloadCount.TarGz)
 	assert.Equal(t, int64(0), release.ArchiveDownloadCount.Zip)
 }
+
+func TestAPIExternalAssetRelease(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+	session := loginUser(t, owner.LowerName)
+	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+	r := createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test")
+
+	req := NewRequest(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets?name=test-asset&external_url=https%%3A%%2F%%2Fforgejo.org%%2F", owner.Name, repo.Name, r.ID)).
+		AddTokenAuth(token)
+	resp := MakeRequest(t, req, http.StatusCreated)
+
+	var attachment *api.Attachment
+	DecodeJSON(t, resp, &attachment)
+
+	assert.EqualValues(t, "test-asset", attachment.Name)
+	assert.EqualValues(t, 0, attachment.Size)
+	assert.EqualValues(t, "https://forgejo.org/", attachment.DownloadURL)
+	assert.EqualValues(t, "external", attachment.Type)
+}
+
+func TestAPIDuplicateAssetRelease(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+	session := loginUser(t, owner.LowerName)
+	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+	r := createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test")
+
+	filename := "image.png"
+	buff := generateImg()
+	body := &bytes.Buffer{}
+
+	writer := multipart.NewWriter(body)
+	part, err := writer.CreateFormFile("attachment", filename)
+	assert.NoError(t, err)
+	_, err = io.Copy(part, &buff)
+	assert.NoError(t, err)
+	err = writer.Close()
+	assert.NoError(t, err)
+
+	req := NewRequestWithBody(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets?name=test-asset&external_url=https%%3A%%2F%%2Fforgejo.org%%2F", owner.Name, repo.Name, r.ID), body).
+		AddTokenAuth(token)
+	req.Header.Add("Content-Type", writer.FormDataContentType())
+	MakeRequest(t, req, http.StatusBadRequest)
+}
+
+func TestAPIMissingAssetRelease(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+	session := loginUser(t, owner.LowerName)
+	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+	r := createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test")
+
+	req := NewRequest(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets?name=test-asset", owner.Name, repo.Name, r.ID)).
+		AddTokenAuth(token)
+	MakeRequest(t, req, http.StatusBadRequest)
+}
diff --git a/tests/integration/mirror_pull_test.go b/tests/integration/mirror_pull_test.go
index 77050c4bbc..ced828f002 100644
--- a/tests/integration/mirror_pull_test.go
+++ b/tests/integration/mirror_pull_test.go
@@ -78,7 +78,7 @@ func TestMirrorPull(t *testing.T) {
 		IsDraft:      false,
 		IsPrerelease: false,
 		IsTag:        true,
-	}, nil, ""))
+	}, "", []*release_service.AttachmentChange{}))
 
 	_, err = repo_model.GetMirrorByRepoID(ctx, mirror.ID)
 	assert.NoError(t, err)
diff --git a/tests/integration/webhook_test.go b/tests/integration/webhook_test.go
index ec85d12b07..4c2b42f880 100644
--- a/tests/integration/webhook_test.go
+++ b/tests/integration/webhook_test.go
@@ -111,7 +111,7 @@ func TestWebhookReleaseEvents(t *testing.T) {
 			IsDraft:      false,
 			IsPrerelease: false,
 			IsTag:        false,
-		}, nil, ""))
+		}, "", nil))
 
 		// check the newly created hooktasks
 		hookTasksLenBefore := len(hookTasks)
@@ -125,7 +125,7 @@ func TestWebhookReleaseEvents(t *testing.T) {
 
 		t.Run("UpdateRelease", func(t *testing.T) {
 			rel := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{RepoID: repo.ID, TagName: "v1.1.1"})
-			assert.NoError(t, release.UpdateRelease(db.DefaultContext, user, gitRepo, rel, nil, nil, nil, false))
+			assert.NoError(t, release.UpdateRelease(db.DefaultContext, user, gitRepo, rel, false, nil))
 
 			// check the newly created hooktasks
 			hookTasksLenBefore := len(hookTasks)
@@ -157,7 +157,7 @@ func TestWebhookReleaseEvents(t *testing.T) {
 
 		t.Run("UpdateRelease", func(t *testing.T) {
 			rel := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{RepoID: repo.ID, TagName: "v1.1.2"})
-			assert.NoError(t, release.UpdateRelease(db.DefaultContext, user, gitRepo, rel, nil, nil, nil, true))
+			assert.NoError(t, release.UpdateRelease(db.DefaultContext, user, gitRepo, rel, true, nil))
 
 			// check the newly created hooktasks
 			hookTasksLenBefore := len(hookTasks)
diff --git a/web_src/js/features/repo-release.js b/web_src/js/features/repo-release.js
index 3784bed2b1..0db9b8ac73 100644
--- a/web_src/js/features/repo-release.js
+++ b/web_src/js/features/repo-release.js
@@ -6,7 +6,8 @@ export function initRepoRelease() {
     el.addEventListener('click', (e) => {
       const uuid = e.target.getAttribute('data-uuid');
       const id = e.target.getAttribute('data-id');
-      document.querySelector(`input[name='attachment-del-${uuid}']`).value = 'true';
+      document.querySelector(`input[name='attachment-del-${uuid}']`).value =
+        'true';
       hideElem(`#attachment-${id}`);
     });
   }
@@ -17,6 +18,7 @@ export function initRepoReleaseNew() {
 
   initTagNameEditor();
   initRepoReleaseEditor();
+  initAddExternalLinkButton();
 }
 
 function initTagNameEditor() {
@@ -45,9 +47,49 @@ function initTagNameEditor() {
 }
 
 function initRepoReleaseEditor() {
-  const editor = document.querySelector('.repository.new.release .combo-markdown-editor');
+  const editor = document.querySelector(
+    '.repository.new.release .combo-markdown-editor',
+  );
   if (!editor) {
     return;
   }
   initComboMarkdownEditor(editor);
 }
+
+let newAttachmentCount = 0;
+
+function initAddExternalLinkButton() {
+  const addExternalLinkButton = document.getElementById('add-external-link');
+  if (!addExternalLinkButton) return;
+
+  addExternalLinkButton.addEventListener('click', () => {
+    newAttachmentCount += 1;
+    const attachmentTemplate = document.getElementById('attachment-template');
+
+    const newAttachment = attachmentTemplate.cloneNode(true);
+    newAttachment.id = `attachment-N${newAttachmentCount}`;
+    newAttachment.classList.remove('tw-hidden');
+
+    const attachmentName = newAttachment.querySelector(
+      'input[name="attachment-template-new-name"]',
+    );
+    attachmentName.name = `attachment-new-name-${newAttachmentCount}`;
+    attachmentName.required = true;
+
+    const attachmentExtUrl = newAttachment.querySelector(
+      'input[name="attachment-template-new-exturl"]',
+    );
+    attachmentExtUrl.name = `attachment-new-exturl-${newAttachmentCount}`;
+    attachmentExtUrl.required = true;
+
+    const attachmentDel = newAttachment.querySelector('.remove-rel-attach');
+    attachmentDel.addEventListener('click', () => {
+      newAttachment.remove();
+    });
+
+    attachmentTemplate.parentNode.insertBefore(
+      newAttachment,
+      attachmentTemplate,
+    );
+  });
+}