From fa0b39a42ba1448f381194a1856f776d7b71f7f1 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sat, 22 Aug 2026 10:59:37 +0200 Subject: [PATCH] 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 --- modelmigration/migrations.go | 1 + modelmigration/v28/v350.go | 28 +++++++++++++++ modelmigration/v28/v350_test.go | 41 +++++++++++++++++++++ models/activities/repo_activity.go | 4 +-- models/fixtures/release.yml | 10 ++++++ models/repo/release.go | 3 +- modules/git/foreachref/parser.go | 2 +- modules/git/repo_tag.go | 13 ++++++- modules/git/repo_tag_test.go | 11 +++++- modules/git/tag.go | 3 ++ modules/repository/repo.go | 15 ++++---- modules/structs/release.go | 2 +- routers/web/feed/convert.go | 2 +- services/convert/release.go | 3 +- services/convert/release_test.go | 4 +++ services/migrations/gitea_uploader.go | 23 ++++++------ services/release/release.go | 11 +++++- services/release/release_test.go | 46 ++++++++++++++++++++++++ services/repository/push.go | 48 ++++++++++++------------- templates/repo/home_sidebar_bottom.tmpl | 2 +- templates/repo/release/list.tmpl | 10 +++--- tests/integration/release_test.go | 23 ++++++------ 22 files changed, 238 insertions(+), 67 deletions(-) create mode 100644 modelmigration/v28/v350.go create mode 100644 modelmigration/v28/v350_test.go diff --git a/modelmigration/migrations.go b/modelmigration/migrations.go index 42563f729be..429dc8308cc 100644 --- a/modelmigration/migrations.go +++ b/modelmigration/migrations.go @@ -423,6 +423,7 @@ func prepareMigrationTasks() []*migration { newMigration(347, "Add watch options", v28.AddWatchOptions), newMigration(348, "Recreate email_hash table for SHA256 avatar hashes", v28.RecreateEmailHashTable), newMigration(349, "Expand action_schedule content column", v28.ExpandActionScheduleContent), + newMigration(350, "Add published_unix column to release", v28.AddPublishedUnixToRelease), } return preparedMigrations } diff --git a/modelmigration/v28/v350.go b/modelmigration/v28/v350.go new file mode 100644 index 00000000000..7167c9e0a88 --- /dev/null +++ b/modelmigration/v28/v350.go @@ -0,0 +1,28 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v28 + +import ( + "context" + + "gitea.dev/modelmigration/base" + + "xorm.io/xorm" +) + +func AddPublishedUnixToRelease(_ context.Context, x base.EngineMigration) error { + type Release struct { + PublishedUnix int64 `xorm:"NOT NULL DEFAULT 0"` + } + if _, err := x.SyncWithOptions(xorm.SyncOptions{ + IgnoreConstrains: true, + IgnoreDropIndices: true, + }, new(Release)); err != nil { + return err + } + + // existing rows have no recorded publication time, so fall back to their creation time + _, err := x.Exec("UPDATE `release` SET published_unix = created_unix WHERE published_unix = 0 AND is_draft = ?", false) + return err +} diff --git a/modelmigration/v28/v350_test.go b/modelmigration/v28/v350_test.go new file mode 100644 index 00000000000..ad56df1ae53 --- /dev/null +++ b/modelmigration/v28/v350_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v28 + +import ( + "testing" + + "gitea.dev/modelmigration/migrationtest" + + "github.com/stretchr/testify/require" +) + +func TestAddPublishedUnixToRelease(t *testing.T) { + type Release struct { + ID int64 `xorm:"pk autoincr"` + IsDraft bool `xorm:"NOT NULL DEFAULT false"` + IsTag bool `xorm:"NOT NULL DEFAULT false"` + CreatedUnix int64 `xorm:"INDEX"` + } + + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(Release)) + defer deferable() + if x == nil || t.Failed() { + return + } + + _, err := x.Insert( + &Release{CreatedUnix: 1000000}, + &Release{IsDraft: true, CreatedUnix: 2000000}, + &Release{IsTag: true, CreatedUnix: 3000000}, + ) + require.NoError(t, err) + + require.NoError(t, AddPublishedUnixToRelease(t.Context(), x)) + + var got []struct{ PublishedUnix int64 } + require.NoError(t, x.Table("release").OrderBy("id").Find(&got)) + require.Equal(t, []int64{1000000, 0, 3000000}, []int64{got[0].PublishedUnix, got[1].PublishedUnix, got[2].PublishedUnix}, + "everything but drafts is backfilled") +} diff --git a/models/activities/repo_activity.go b/models/activities/repo_activity.go index c6f4bf2997b..2e3af13db52 100644 --- a/models/activities/repo_activity.go +++ b/models/activities/repo_activity.go @@ -367,7 +367,7 @@ func (stats *ActivityStats) FillReleases(ctx context.Context, repoID int64, from // Published releases list sess := releasesForActivityStatement(ctx, repoID, fromTime) - sess.OrderBy("`release`.created_unix DESC") + sess.OrderBy("`release`.published_unix DESC") stats.PublishedReleases = make([]*repo_model.Release, 0) if err = sess.Find(&stats.PublishedReleases); err != nil { return err @@ -386,5 +386,5 @@ func (stats *ActivityStats) FillReleases(ctx context.Context, repoID int64, from func releasesForActivityStatement(ctx context.Context, repoID int64, fromTime time.Time) db.Session { return db.GetEngine(ctx).Where("`release`.repo_id = ?", repoID). And("`release`.is_draft = ?", false). - And("`release`.created_unix >= ?", fromTime.Unix()) + And("`release`.published_unix >= ?", fromTime.Unix()) } diff --git a/models/fixtures/release.yml b/models/fixtures/release.yml index be44e331ec0..41749a4e26b 100644 --- a/models/fixtures/release.yml +++ b/models/fixtures/release.yml @@ -11,6 +11,7 @@ is_prerelease: false is_tag: false created_unix: 946684800 + published_unix: 946684800 - id: 2 repo_id: 40 @@ -25,6 +26,7 @@ is_prerelease: false is_tag: false created_unix: 946684800 + published_unix: 946684800 - id: 3 repo_id: 1 @@ -39,6 +41,7 @@ is_prerelease: false is_tag: true created_unix: 946684800 + published_unix: 946684800 - id: 4 repo_id: 1 @@ -66,6 +69,7 @@ is_prerelease: true is_tag: false created_unix: 946684800 + published_unix: 946684800 - id: 6 repo_id: 57 @@ -80,6 +84,7 @@ is_prerelease: false is_tag: false created_unix: 946684801 + published_unix: 946684801 - id: 7 repo_id: 57 @@ -94,6 +99,7 @@ is_prerelease: false is_tag: false created_unix: 946684802 + published_unix: 946684802 - id: 8 repo_id: 57 @@ -108,6 +114,7 @@ is_prerelease: false is_tag: false created_unix: 946684803 + published_unix: 946684803 - id: 9 repo_id: 57 @@ -122,6 +129,7 @@ is_prerelease: false is_tag: false created_unix: 946684803 + published_unix: 946684803 - id: 10 repo_id: 57 @@ -136,6 +144,7 @@ is_prerelease: false is_tag: false created_unix: 946684803 + published_unix: 946684803 - id: 11 repo_id: 2 @@ -150,5 +159,6 @@ is_prerelease: false is_tag: false created_unix: 946684803 + published_unix: 946684803 # DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/repo/release.go b/models/repo/release.go index f10ecc89409..9dd1ab395d7 100644 --- a/models/repo/release.go +++ b/models/repo/release.go @@ -87,6 +87,7 @@ type Release struct { IsTag bool `xorm:"NOT NULL DEFAULT false"` // will be true only if the record is a tag and has no related releases Attachments []*Attachment `xorm:"-"` CreatedUnix timeutil.TimeStamp `xorm:"INDEX"` + PublishedUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"` } func init() { @@ -473,7 +474,7 @@ func PushUpdateDeleteTags(ctx context.Context, repo *Repository, tags []string) if _, err := db.GetEngine(ctx). Where("repo_id = ? AND is_tag = ?", repo.ID, false). In("lower_tag_name", lowerTags). - Cols("is_draft", "num_commits", "sha1"). + Cols("is_draft", "num_commits", "sha1", "published_unix"). Update(&Release{ IsDraft: true, }); err != nil { diff --git a/modules/git/foreachref/parser.go b/modules/git/foreachref/parser.go index 874b3744c30..cf6afaffbca 100644 --- a/modules/git/foreachref/parser.go +++ b/modules/git/foreachref/parser.go @@ -103,7 +103,7 @@ func (p *Parser) parseRef(refBlock string) (map[string]string, error) { return nil, nil //nolint:nilnil // return nil to signal EOF } - fieldValues := make(map[string]string) + fieldValues := make(map[string]string, len(p.format.fieldNames)) fields := strings.Split(refBlock, p.format.fieldDelimStr) if len(fields) != len(p.format.fieldNames) { diff --git a/modules/git/repo_tag.go b/modules/git/repo_tag.go index 871a3db68e4..028764d9d3d 100644 --- a/modules/git/repo_tag.go +++ b/modules/git/repo_tag.go @@ -7,7 +7,9 @@ package git import ( "context" "fmt" + "strconv" "strings" + "time" "gitea.dev/modules/git/foreachref" "gitea.dev/modules/git/gitcmd" @@ -113,7 +115,7 @@ func (repo *Repository) GetTagWithID(ctx context.Context, idStr, name string) (* func (repo *Repository) GetTagInfos(ctx context.Context, page, pageSize int) ([]*Tag, int, error) { // Generally, refname:short should be equal to refname:lstrip=2 except core.warnAmbiguousRefs is used to select the strict abbreviation mode. // https://git-scm.com/docs/git-for-each-ref#Documentation/git-for-each-ref.txt-refname - forEachRefFmt := foreachref.NewFormat("objecttype", "refname:lstrip=2", "object", "objectname", "creator", "contents", "contents:signature") + forEachRefFmt := foreachref.NewFormat("objecttype", "refname:lstrip=2", "object", "objectname", "creator", "contents", "contents:signature", "committerdate:unix", "*committerdate:unix") var tags []*Tag var tagsTotal int @@ -179,6 +181,15 @@ func parseTagRef(ref map[string]string) (tag *Tag, err error) { tag.Tagger = parseSignatureFromCommitLine(ref["creator"]) tag.MessageRaw = ref["contents"] + // a lightweight tag reports the commit date directly, an annotated one only behind the dereferencing "*" + if committerDate := util.IfZero(ref["*committerdate:unix"], ref["committerdate:unix"]); committerDate != "" { + seconds, err := strconv.ParseInt(committerDate, 10, 64) + if err != nil { + return nil, fmt.Errorf("parse committerdate '%s': %w", committerDate, err) + } + tag.CommitDate = time.Unix(seconds, 0) + } + // strip any signature if present in contents field _, tag.MessageRaw, _ = parsePayloadSignature(util.UnsafeStringToBytes(tag.MessageRaw), 0) diff --git a/modules/git/repo_tag_test.go b/modules/git/repo_tag_test.go index e557f45d297..6c9f8e199ce 100644 --- a/modules/git/repo_tag_test.go +++ b/modules/git/repo_tag_test.go @@ -6,6 +6,7 @@ package git import ( "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -33,6 +34,7 @@ func TestRepository_GetTagInfos(t *testing.T) { assert.Equal(t, "test", tags[1].Name) assert.Equal(t, "3ad28a9149a2864384548f3d17ed7f38014c9e8a", tags[1].ID.String()) assert.Equal(t, "tag", tags[1].Type) + assert.False(t, tags[0].CommitDate.IsZero(), "for-each-ref resolves the tagged commit's date") } func TestRepository_GetTag(t *testing.T) { @@ -207,7 +209,9 @@ func TestRepository_parseTagRef(t *testing.T) { * add changelog of v1.9.1 * Update CHANGELOG.md `, - "contents:signature": "", + "contents:signature": "", + "committerdate:unix": "1565789218", + "*committerdate:unix": "", }, want: &Tag{ @@ -216,6 +220,7 @@ func TestRepository_parseTagRef(t *testing.T) { Object: MustIDFromString("ab23e4b7f4cd0caafe0174c0e7ef6d651ba72889"), Type: "commit", Tagger: parseSignatureFromCommitLine("Foo Bar 1565789218 +0300"), + CommitDate: time.Unix(1565789218, 0), CommitMessage: CommitMessage{MessageRaw: "Add changelog of v1.9.1 (#7859)\n\n* add changelog of v1.9.1\n* Update CHANGELOG.md\n"}, Signature: nil, }, @@ -237,6 +242,9 @@ func TestRepository_parseTagRef(t *testing.T) { * Update CHANGELOG.md `, "contents:signature": "", + // an annotated tag can be made long after the commit, so its own date is not the commit date + "committerdate:unix": "", + "*committerdate:unix": "1565700000", }, want: &Tag{ @@ -245,6 +253,7 @@ func TestRepository_parseTagRef(t *testing.T) { Object: MustIDFromString("3325fd8a973321fd59455492976c042dde3fd1ca"), Type: "tag", Tagger: parseSignatureFromCommitLine("Foo Bar 1565789218 +0300"), + CommitDate: time.Unix(1565700000, 0), CommitMessage: CommitMessage{MessageRaw: "Add changelog of v1.9.1 (#7859)\n\n* add changelog of v1.9.1\n* Update CHANGELOG.md\n"}, Signature: nil, }, diff --git a/modules/git/tag.go b/modules/git/tag.go index f7d44f8f319..496363f2b14 100644 --- a/modules/git/tag.go +++ b/modules/git/tag.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "sort" + "time" "gitea.dev/modules/util" ) @@ -21,6 +22,8 @@ type Tag struct { Type string Tagger *Signature Signature *CommitSignature + + CommitDate time.Time // committer date of Object, only GetTagInfos resolves it } func parsePayloadSignature(data []byte, messageStart int) (payload, msg, sign string) { diff --git a/modules/repository/repo.go b/modules/repository/repo.go index aced7d6031e..02534587614 100644 --- a/modules/repository/repo.go +++ b/modules/repository/repo.go @@ -18,6 +18,7 @@ import ( "gitea.dev/modules/log" "gitea.dev/modules/setting" "gitea.dev/modules/timeutil" + "gitea.dev/modules/util" ) /* @@ -245,9 +246,10 @@ func SyncReleasesWithTags(ctx context.Context, repo *repo_model.Repository, gitR LowerTagName: strings.ToLower(tag.Name), Sha1: tag.Object.String(), // NOTE: ignored, The NumCommits value is calculated and cached on demand when the UI requires it. - NumCommits: -1, - CreatedUnix: timeutil.TimeStamp(tag.Tagger.When.Unix()), - IsTag: true, + NumCommits: -1, + CreatedUnix: timeutil.TimeStamp(util.IfZero(tag.CommitDate, tag.Tagger.When).Unix()), + PublishedUnix: timeutil.TimeStamp(tag.Tagger.When.Unix()), + IsTag: true, } if err := db.Insert(ctx, release); err != nil { return fmt.Errorf("unable insert tag %s for pull-mirror Repo[%d:%s/%s]: %w", tag.Name, repo.ID, repo.OwnerName, repo.Name, err) @@ -265,10 +267,11 @@ func SyncReleasesWithTags(ctx context.Context, repo *repo_model.Repository, gitR for _, tag := range updates { if _, err := db.GetEngine(ctx).Where("repo_id = ? AND lower_tag_name = ?", repo.ID, strings.ToLower(tag.Name)). - Cols("sha1", "created_unix"). + Cols("sha1", "created_unix", "published_unix"). Update(&repo_model.Release{ - Sha1: tag.Object.String(), - CreatedUnix: timeutil.TimeStamp(tag.Tagger.When.Unix()), + Sha1: tag.Object.String(), + CreatedUnix: timeutil.TimeStamp(util.IfZero(tag.CommitDate, tag.Tagger.When).Unix()), + PublishedUnix: timeutil.TimeStamp(tag.Tagger.When.Unix()), }); err != nil { return fmt.Errorf("unable to update tag %s for pull-mirror Repo[%d:%s/%s]: %w", tag.Name, repo.ID, repo.OwnerName, repo.Name, err) } diff --git a/modules/structs/release.go b/modules/structs/release.go index 6a3e87ccbc9..b9c6e6a7964 100644 --- a/modules/structs/release.go +++ b/modules/structs/release.go @@ -36,7 +36,7 @@ type Release struct { // swagger:strfmt date-time CreatedAt time.Time `json:"created_at"` // swagger:strfmt date-time - PublishedAt time.Time `json:"published_at"` + PublishedAt *time.Time `json:"published_at"` // The user who published the release Publisher *User `json:"author"` // The files attached to the release diff --git a/routers/web/feed/convert.go b/routers/web/feed/convert.go index 8f1dd8ec67f..51babbb0d29 100644 --- a/routers/web/feed/convert.go +++ b/routers/web/feed/convert.go @@ -301,7 +301,7 @@ func releasesToFeedItems(ctx *context.Context, releases []*repo_model.Release) ( items = append(items, &feeds.Item{ Title: title, Link: link, - Created: rel.CreatedUnix.AsTime(), + Created: rel.PublishedUnix.AsTime(), Author: &feeds.Author{ Name: rel.Publisher.GetDisplayName(), Email: rel.Publisher.GetEmail(), diff --git a/services/convert/release.go b/services/convert/release.go index 74b39fbf531..f3f08ca9b8c 100644 --- a/services/convert/release.go +++ b/services/convert/release.go @@ -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), } diff --git a/services/convert/release_test.go b/services/convert/release_test.go index 614e2fb0c12..5b121416c00 100644 --- a/services/convert/release_test.go +++ b/services/convert/release_test.go @@ -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") } diff --git a/services/migrations/gitea_uploader.go b/services/migrations/gitea_uploader.go index 6c87c89d536..c610443147d 100644 --- a/services/migrations/gitea_uploader.go +++ b/services/migrations/gitea_uploader.go @@ -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 { diff --git a/services/release/release.go b/services/release/release.go index e49fbcc2585..f5a31f2acea 100644 --- a/services/release/release.go +++ b/services/release/release.go @@ -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 { diff --git a/services/release/release_test.go b/services/release/release_test.go index 5dfaed384d8..f45ab2030e5 100644 --- a/services/release/release_test.go +++ b/services/release/release_test.go @@ -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) +} diff --git a/services/repository/push.go b/services/repository/push.go index 017a152b6db..a8b8187b633 100644 --- a/services/repository/push.go +++ b/services/repository/push.go @@ -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 { diff --git a/templates/repo/home_sidebar_bottom.tmpl b/templates/repo/home_sidebar_bottom.tmpl index be3f324e74d..2d1d61f59fa 100644 --- a/templates/repo/home_sidebar_bottom.tmpl +++ b/templates/repo/home_sidebar_bottom.tmpl @@ -15,7 +15,7 @@ {{template "repo/release/label" (dict "Release" .LatestRelease "IsLatest" true)}} -
{{DateUtils.TimeSince .LatestRelease.CreatedUnix}}
+
{{DateUtils.TimeSince .LatestRelease.PublishedUnix}}
{{end}} diff --git a/templates/repo/release/list.tmpl b/templates/repo/release/list.tmpl index 1009d224372..90870e54989 100644 --- a/templates/repo/release/list.tmpl +++ b/templates/repo/release/list.tmpl @@ -59,11 +59,11 @@ Ghost {{end}} - - {{ctx.Locale.Tr "repo.released_this"}} - - {{if $release.CreatedUnix}} - {{DateUtils.TimeSince $release.CreatedUnix}} + {{if not $release.IsDraft}} + + {{ctx.Locale.Tr "repo.released_this"}} + + {{DateUtils.TimeSince $release.PublishedUnix}} {{end}} {{if and (gt $release.NumCommits 0) (not $release.IsDraft) ($.Permission.CanRead ctx.Consts.RepoUnitTypeCode)}} | {{ctx.Locale.Tr "repo.release.ahead.commits" $release.NumCommitsBehind}} {{ctx.Locale.Tr "repo.release.ahead.target" $release.TargetBehind}} diff --git a/tests/integration/release_test.go b/tests/integration/release_test.go index 7fd9d1f10b5..4526e37cb03 100644 --- a/tests/integration/release_test.go +++ b/tests/integration/release_test.go @@ -46,18 +46,21 @@ func createNewRelease(t *testing.T, session *TestSession, repoURL, tag, title st assert.NotEmpty(t, test.ParseJSONRedirect(resp.Body.Bytes())) } -func checkLatestReleaseAndCount(t *testing.T, session *TestSession, repoURL, version, label string, count int) { +// returns the first listed title, which is not necessarily the one just created because releases are ordered by commit date +func checkLatestReleaseAndCount(t *testing.T, session *TestSession, repoURL, version, label string, count int) string { req := NewRequest(t, "GET", repoURL+"/releases") resp := session.MakeRequest(t, req, http.StatusOK) - htmlDoc := NewHTMLParser(t, resp.Body) - labelText := htmlDoc.doc.Find("#release-list > li .detail .label").First().Text() - assert.Equal(t, label, labelText) - titleText := htmlDoc.doc.Find("#release-list > li .detail h4 a").First().Text() - assert.Equal(t, version, titleText) - - releaseList := htmlDoc.doc.Find("#release-list > li") + releaseList := NewHTMLParser(t, resp.Body).doc.Find("#release-list > li") assert.Equal(t, count, releaseList.Length()) + + item := releaseList.FilterFunction(func(_ int, selection *goquery.Selection) bool { + return selection.Find(".detail h4 a").Text() == version + }) + if assert.Equal(t, 1, item.Length(), "release %q is listed exactly once", version) { + assert.Equal(t, label, item.Find(".detail .label").First().Text()) + } + return releaseList.Find(".detail h4 a").First().Text() } func TestViewReleases(t *testing.T) { @@ -113,11 +116,11 @@ func TestCreateReleasePaging(t *testing.T) { } createNewRelease(t, session, "/user2/repo1", "v0.0.12", "v0.0.12", false, true) - checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.12", translation.NewLocale("en-US").TrString("repo.release.draft"), 10) + assert.Equal(t, "v0.0.12", checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.12", translation.NewLocale("en-US").TrString("repo.release.draft"), 10)) // Check that user4 does not see draft and still see 10 latest releases session2 := loginUser(t, "user4") - checkLatestReleaseAndCount(t, session2, "/user2/repo1", "v0.0.11", translation.NewLocale("en-US").TrString("repo.release.stable"), 10) + assert.Equal(t, "v0.0.11", checkLatestReleaseAndCount(t, session2, "/user2/repo1", "v0.0.11", translation.NewLocale("en-US").TrString("repo.release.stable"), 10)) } func TestViewReleaseListNoLogin(t *testing.T) {