fix(release): separate publication time from the release date (#36761)

`published_at` was an alias for `created_at`, so a release created from
an existing tag reported that tag's commit date as its publication time,
and drafts reported one despite never having been published. It is now
stored separately, set when a release is published and null for drafts.

`created_at` in turn means the date of the commit the release points at,
matching what GitHub documents it to be, and the latest release is
selected by it again. Publishing a release for an old commit no longer
takes over the latest badge, and a tag created in the web UI is dated
the same way as one pushed from the CLI.

Fixes https://github.com/go-gitea/gitea/issues/11206
Fixes https://github.com/go-gitea/gitea/issues/38714
Fixes https://github.com/go-gitea/gitea/issues/31789

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
silverwind
2026-08-22 08:59:37 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 6bb6ce678b
commit fa0b39a42b
22 changed files with 238 additions and 67 deletions
+2 -1
View File
@@ -8,6 +8,7 @@ import (
repo_model "gitea.dev/models/repo"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
)
// ToAPIRelease convert a repo_model.Release to api.Release
@@ -26,7 +27,7 @@ func ToAPIRelease(ctx context.Context, repo *repo_model.Repository, r *repo_mode
IsDraft: r.IsDraft,
IsPrerelease: r.IsPrerelease,
CreatedAt: r.CreatedUnix.AsTime(),
PublishedAt: r.CreatedUnix.AsTime(),
PublishedAt: util.Iif(r.IsDraft, nil, r.PublishedUnix.AsTimePtr()),
Publisher: ToUser(ctx, r.Publisher, nil),
Attachments: ToAPIAttachments(repo, r.Attachments),
}
+4
View File
@@ -24,4 +24,8 @@ func TestRelease_ToRelease(t *testing.T) {
assert.EqualValues(t, 1, apiRelease.ID)
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/releases/1", apiRelease.URL)
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/releases/1/assets", apiRelease.UploadURL)
assert.Equal(t, release1.PublishedUnix.AsTimePtr(), apiRelease.PublishedAt)
release1.IsDraft = true
assert.Nil(t, ToAPIRelease(t.Context(), repo1, release1).PublishedAt, "an unpublished release has no publication date")
}
+13 -10
View File
@@ -278,17 +278,20 @@ func (g *GiteaLocalUploader) CreateReleases(ctx context.Context, releases ...*ba
release.TargetCommitish = ""
}
publishedAt := util.Iif(release.Published.IsZero(), release.Created, release.Published)
rel := repo_model.Release{
RepoID: g.repo.ID,
TagName: release.TagName,
LowerTagName: strings.ToLower(release.TagName),
Target: release.TargetCommitish,
Title: release.Name,
Note: release.Body,
IsDraft: release.Draft,
IsPrerelease: release.Prerelease,
IsTag: false,
CreatedUnix: timeutil.TimeStamp(release.Created.Unix()),
RepoID: g.repo.ID,
TagName: release.TagName,
LowerTagName: strings.ToLower(release.TagName),
Target: release.TargetCommitish,
Title: release.Name,
Note: release.Body,
IsDraft: release.Draft,
IsPrerelease: release.Prerelease,
IsTag: false,
CreatedUnix: timeutil.TimeStamp(release.Created.Unix()),
PublishedUnix: util.Iif(release.Draft, 0, timeutil.TimeStamp(publishedAt.Unix())),
}
if err := g.remapUser(ctx, release, &rel); err != nil {
+10 -1
View File
@@ -140,7 +140,9 @@ func createTag(ctx context.Context, gitRepo *git.Repository, rel *repo_model.Rel
NewCommitID: commit.ID.String(),
}, commits)
notify_service.CreateRef(ctx, rel.Publisher, rel.Repo, refFullName, commit.ID.String())
rel.CreatedUnix = timeutil.TimeStampNow()
}
if rel.PublishedUnix.IsZero() {
rel.PublishedUnix = timeutil.TimeStampNow()
}
commit, err := gitRepo.GetTagCommit(ctx, rel.TagName)
if err != nil {
@@ -148,6 +150,7 @@ func createTag(ctx context.Context, gitRepo *git.Repository, rel *repo_model.Rel
}
rel.Sha1 = commit.ID.String()
rel.CreatedUnix = timeutil.TimeStamp(commit.Committer.When.Unix()) // dated by its commit, so an old commit does not become the latest release
rel.NumCommits, err = git.CommitsCountOfCommit(ctx, rel.Repo, commit.ID.String())
if err != nil {
return false, fmt.Errorf("CommitsCount: %w", err)
@@ -273,6 +276,12 @@ func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repo
return err
}
isConvertedFromTag := oldRelease.IsTag && !rel.IsTag
// a zero PublishedUnix means "draft", so withdrawing a release has to clear it again
if rel.IsDraft {
rel.PublishedUnix = 0
} else if isConvertedFromTag || oldRelease.IsDraft {
rel.PublishedUnix = timeutil.TimeStampNow()
}
if err := db.WithTx(ctx, func(ctx context.Context) error {
if err = repo_model.UpdateRelease(ctx, rel); err != nil {
+46
View File
@@ -165,12 +165,14 @@ func TestRelease_Update(t *testing.T) {
release, err := repo_model.GetRelease(t.Context(), repo.ID, "v1.1.1")
assert.NoError(t, err)
releaseCreatedUnix := release.CreatedUnix
releasePublishedUnix := release.PublishedUnix
advance()
release.Note = "Changed note"
assert.NoError(t, UpdateRelease(t.Context(), user, gitRepo, release, nil, nil, nil))
release, err = repo_model.GetReleaseByID(t.Context(), release.ID)
assert.NoError(t, err)
assert.Equal(t, int64(releaseCreatedUnix), int64(release.CreatedUnix))
assert.Equal(t, releasePublishedUnix, release.PublishedUnix, "editing does not republish")
// Test a changed draft
assert.NoError(t, CreateRelease(t.Context(), gitRepo, &repo_model.Release{
@@ -195,6 +197,19 @@ func TestRelease_Update(t *testing.T) {
release, err = repo_model.GetReleaseByID(t.Context(), release.ID)
assert.NoError(t, err)
assert.Less(t, int64(releaseCreatedUnix), int64(release.CreatedUnix))
assert.Zero(t, release.PublishedUnix, "a draft is unpublished")
// Test publishing and withdrawing that draft
release.IsDraft = false
assert.NoError(t, UpdateRelease(t.Context(), user, gitRepo, release, nil, nil, nil))
release, err = repo_model.GetReleaseByID(t.Context(), release.ID)
assert.NoError(t, err)
assert.NotZero(t, release.PublishedUnix, "publishing stamps the publication time")
release.IsDraft = true
assert.NoError(t, UpdateRelease(t.Context(), user, gitRepo, release, nil, nil, nil))
release, err = repo_model.GetReleaseByID(t.Context(), release.ID)
assert.NoError(t, err)
assert.Zero(t, release.PublishedUnix, "withdrawing unpublishes it again")
// Test a changed pre-release
assert.NoError(t, CreateRelease(t.Context(), gitRepo, &repo_model.Release{
@@ -387,3 +402,34 @@ func TestCreateNewTag(t *testing.T) {
assert.NoError(t, CreateNewTag(t.Context(), user, repo, "master", "v2.0",
"v2.0 is released \n\n BUGFIX: .... \n\n 123"))
}
func TestRelease_DatedByTargetCommit(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
gitRepo, err := git.OpenRepository(t.Context(), repo)
assert.NoError(t, err)
defer gitRepo.Close()
newRelease := func(tagName, target string) *repo_model.Release {
rel := &repo_model.Release{
RepoID: repo.ID, Repo: repo, PublisherID: user.ID, Publisher: user,
TagName: tagName, Target: target, Title: tagName,
}
assert.NoError(t, CreateRelease(t.Context(), gitRepo, rel, nil, ""))
return rel
}
recent := newRelease("v9.9-recent", "DefaultBranch")
// released afterwards, but from an older commit, so it must not take over as the latest release
old := newRelease("v9.9-old", "master")
oldCommit, err := gitRepo.GetBranchCommit(t.Context(), "master")
assert.NoError(t, err)
assert.Equal(t, oldCommit.Committer.When.Unix(), int64(old.CreatedUnix), "a release is dated by the commit it points at")
assert.Greater(t, int64(old.PublishedUnix), int64(old.CreatedUnix), "but its publication time is now")
latest, err := repo_model.GetLatestReleaseByRepoID(t.Context(), repo.ID)
assert.NoError(t, err)
assert.Equal(t, recent.ID, latest.ID)
}
+23 -25
View File
@@ -368,47 +368,45 @@ func pushUpdateAddTags(ctx context.Context, repo *repo_model.Repository, gitRepo
return fmt.Errorf("Commit: %w", err)
}
sig := tag.Tagger
if sig == nil {
sig = commit.Author
}
if sig == nil {
sig = commit.Committer
}
createdAt := time.Unix(1, 0)
if sig != nil {
createdAt = sig.When
createdUnix := timeutil.TimeStamp(commit.Committer.When.Unix()) // tagged whenever, but dated by its commit
publishedUnix := createdUnix
if tag.Tagger != nil {
publishedUnix = timeutil.TimeStamp(tag.Tagger.When.Unix())
}
rel, has := relMap[lowerTag]
title, note := git.SplitCommitTitleBody(tag.MessageUTF8(), 255)
if !has {
rel = &repo_model.Release{
RepoID: repo.ID,
Title: title,
TagName: tags[i],
LowerTagName: lowerTag,
Target: "",
Sha1: commit.ID.String(),
NumCommits: -1, // the commits count will be updated when the UI needs it
Note: note,
IsDraft: false,
IsPrerelease: false,
IsTag: true,
PublisherID: pusher.ID,
CreatedUnix: timeutil.TimeStamp(createdAt.Unix()),
RepoID: repo.ID,
Title: title,
TagName: tags[i],
LowerTagName: lowerTag,
Target: "",
Sha1: commit.ID.String(),
NumCommits: -1, // the commits count will be updated when the UI needs it
Note: note,
IsDraft: false,
IsPrerelease: false,
IsTag: true,
PublisherID: pusher.ID,
CreatedUnix: createdUnix,
PublishedUnix: publishedUnix,
}
newReleases = append(newReleases, rel)
} else {
rel.Sha1 = commit.ID.String()
rel.CreatedUnix = timeutil.TimeStamp(createdAt.Unix())
rel.CreatedUnix = createdUnix
if rel.IsTag {
rel.Title = title
rel.Note = note
rel.PublishedUnix = publishedUnix
} else {
rel.IsDraft = false
if rel.PublishedUnix.IsZero() {
rel.PublishedUnix = timeutil.TimeStampNow()
}
}
rel.PublisherID = pusher.ID
if err = repo_model.UpdateRelease(ctx, rel); err != nil {