diff --git a/models/licenses/license_key.go b/models/licenses/license_key.go index 13ef323b9f..5344e660a0 100644 --- a/models/licenses/license_key.go +++ b/models/licenses/license_key.go @@ -33,7 +33,7 @@ type LicenseKey struct { LicenseeEmail string `xorm:""` // customer email DomainRestriction string `xorm:"TEXT"` // comma-separated allowed domains MaxSites int `xorm:"NOT NULL DEFAULT 0"` // 0 = use package default - PaymentRef string `xorm:"UNIQUE"` // idempotency key from payment system + PaymentRef string `xorm:""` // idempotency key from payment system IsInternal bool `xorm:"NOT NULL DEFAULT false"` // true = base org/repo key IsActive bool `xorm:"NOT NULL DEFAULT true"` StartsUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"` // custom start, 0 = creation diff --git a/models/licenses/release_stream_map.go b/models/licenses/release_stream_map.go new file mode 100644 index 0000000000..5a4a7b8e68 --- /dev/null +++ b/models/licenses/release_stream_map.go @@ -0,0 +1,88 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package licenses + +import ( + "context" + + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" +) + +func init() { + db.RegisterModel(new(ReleaseStreamMap)) +} + +// ReleaseStreamMap manually assigns a release to an update stream. +// When present, overrides automatic stream detection from tag names. +type ReleaseStreamMap struct { + ID int64 `xorm:"pk autoincr"` + ReleaseID int64 `xorm:"UNIQUE NOT NULL INDEX"` // FK to release + RepoID int64 `xorm:"NOT NULL INDEX"` // for fast repo-scoped queries + StreamName string `xorm:"NOT NULL"` // e.g. "stable", "release-candidate" + CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"` +} + +func (ReleaseStreamMap) TableName() string { + return "release_stream_map" +} + +// SetReleaseStream assigns or updates the stream for a release. +func SetReleaseStream(ctx context.Context, releaseID, repoID int64, streamName string) error { + existing := new(ReleaseStreamMap) + has, err := db.GetEngine(ctx).Where("release_id = ?", releaseID).Get(existing) + if err != nil { + return err + } + + if has { + existing.StreamName = streamName + _, err = db.GetEngine(ctx).ID(existing.ID).Cols("stream_name").Update(existing) + return err + } + + _, err = db.GetEngine(ctx).Insert(&ReleaseStreamMap{ + ReleaseID: releaseID, + RepoID: repoID, + StreamName: streamName, + }) + return err +} + +// GetReleaseStream returns the manually assigned stream for a release, or empty string. +func GetReleaseStream(ctx context.Context, releaseID int64) string { + m := new(ReleaseStreamMap) + has, err := db.GetEngine(ctx).Where("release_id = ?", releaseID).Get(m) + if err != nil || !has { + return "" + } + return m.StreamName +} + +// GetStreamMapForRepo returns all manual stream assignments for a repo. +func GetStreamMapForRepo(ctx context.Context, repoID int64) (map[int64]string, error) { + var maps []ReleaseStreamMap + if err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Find(&maps); err != nil { + return nil, err + } + result := make(map[int64]string, len(maps)) + for _, m := range maps { + result[m.ReleaseID] = m.StreamName + } + return result, nil +} + +// ResolveReleaseStream returns the stream for a release: manual mapping first, auto-detect fallback. +func ResolveReleaseStream(ctx context.Context, releaseID int64, tagName string, isPrerelease bool, streams []StreamDef) string { + if manual := GetReleaseStream(ctx, releaseID); manual != "" { + return manual + } + return MatchStreamFromTag(tagName, isPrerelease, streams) +} + +// DeleteReleaseStream removes the manual stream assignment for a release. +func DeleteReleaseStream(ctx context.Context, releaseID int64) error { + _, err := db.GetEngine(ctx).Where("release_id = ?", releaseID).Delete(new(ReleaseStreamMap)) + return err +} diff --git a/models/licenses/update_stream_config.go b/models/licenses/update_stream_config.go index a411416507..ed63f52467 100644 --- a/models/licenses/update_stream_config.go +++ b/models/licenses/update_stream_config.go @@ -171,10 +171,20 @@ func SaveConfig(ctx context.Context, cfg *UpdateStreamConfig) error { } // MatchStreamFromTag determines which stream a tag belongs to based on the given stream definitions. +// Supports two conventions: +// - Stream-name tags: tag IS the stream name (e.g. "stable", "release-candidate", "development") +// - Version tags: tag contains a version + optional suffix (e.g. "v1.0.0", "v1.0.0-rc1") func MatchStreamFromTag(tagName string, isPrerelease bool, streams []StreamDef) string { lower := strings.ToLower(tagName) - // Check custom suffixes (longest match first to avoid "-rc" matching before "-rc-special"). + // First: check if the tag name directly matches a stream name (stream-name convention). + for _, s := range streams { + if strings.EqualFold(s.Name, tagName) { + return s.Name + } + } + + // Second: check suffixes in the tag (version-tag convention, longest match first). var bestMatch string bestLen := 0 for _, s := range streams { diff --git a/models/migrations/v1_27/v340.go b/models/migrations/v1_27/v340.go index 1f26764e00..3dc3df6e38 100644 --- a/models/migrations/v1_27/v340.go +++ b/models/migrations/v1_27/v340.go @@ -13,7 +13,7 @@ import ( type licenseKey340 struct { ID int64 `xorm:"pk autoincr"` KeyRaw string `xorm:"TEXT"` - PaymentRef string `xorm:"UNIQUE"` + PaymentRef string `xorm:""` LastHeartbeatUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"` FirstUsedUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"` } @@ -55,12 +55,25 @@ func (updateStreamConfig340) TableName() string { return "update_stream_config" } -// SyncLicenseSystemColumns adds missing columns to license_key, -// license_package, and update_stream_config tables. +// releaseStreamMap340 creates the release-to-stream manual mapping table. +type releaseStreamMap340 struct { + ID int64 `xorm:"pk autoincr"` + ReleaseID int64 `xorm:"UNIQUE NOT NULL INDEX"` + RepoID int64 `xorm:"NOT NULL INDEX"` + StreamName string `xorm:"NOT NULL"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"` +} + +func (releaseStreamMap340) TableName() string { + return "release_stream_map" +} + +// SyncLicenseSystemColumns adds missing columns and creates new tables. func SyncLicenseSystemColumns(x *xorm.Engine) error { return x.Sync( new(licenseKey340), new(licensePackage340), new(updateStreamConfig340), + new(releaseStreamMap340), ) } diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index ccc4e31619..1e840e6c90 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2714,6 +2714,9 @@ "repo.settings.download_gating": "Download Gating", "repo.settings.support_url": "Support / Product Page URL", "repo.settings.support_url_help": "Shown when downloads are gated. Can point to your wiki, product page, or external support site.", + "repo.release.update_stream": "Update Stream", + "repo.release.update_stream_auto": "(auto-detect from tag name)", + "repo.release.update_stream_help": "Assign this release to an update stream. The update feed will serve the latest release per stream.", "repo.release.downloads_require_login": "Sign in to download release files.", "repo.settings.extension_metadata": "Extension Metadata", "repo.settings.extension_metadata_desc": "Override the org-level extension metadata for this repository. Empty fields inherit from the organization settings.", diff --git a/routers/web/repo/changelog_xml.go b/routers/web/repo/changelog_xml.go index 3f56b0e005..dc054919f8 100644 --- a/routers/web/repo/changelog_xml.go +++ b/routers/web/repo/changelog_xml.go @@ -77,6 +77,12 @@ func ServeChangelogXML(ctx *context.Context) { } version := extractVersionFromTag(rel.TagName) + // If the tag is a stream name, try the release title for the version. + if version == rel.TagName && (version == "stable" || version == "release-candidate" || version == "beta" || version == "alpha" || version == "development") { + if titleVer := extractVersionFromTag(rel.Title); titleVer != "" { + version = titleVer + } + } cl := xmlChangelog{ Element: element, Type: extType, diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go index 22a15913c0..6f7484112a 100644 --- a/routers/web/repo/release.go +++ b/routers/web/repo/release.go @@ -14,6 +14,7 @@ import ( "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git" + licenses_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/renderhelper" repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unit" @@ -355,6 +356,17 @@ func newReleaseCommon(ctx *context.Context) { upload.AddUploadContext(ctx, "release") + // Load available streams for the stream selector (when licensing enabled). + if ctx.Data["LicensingEnabled"] == true { + ownerID := ctx.Repo.Repository.OwnerID + orgCfg, _ := licenses_model.GetOrgConfig(ctx, ownerID) + if orgCfg != nil { + ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams() + } else { + ctx.Data["AvailableStreams"] = licenses_model.DefaultJoomlaStreams() + } + } + PrepareBranchList(ctx) // for New Release page } @@ -520,6 +532,10 @@ func NewReleasePost(ctx *context.Context) { handleTagReleaseError(err) return } + // Save manual stream assignment if specified. + if streamName := form.UpdateStream; streamName != "" { + _ = licenses_model.SetReleaseStream(ctx, rel.ID, rel.RepoID, streamName) + } ctx.Redirect(ctx.Repo.RepoLink + "/releases") return } @@ -580,6 +596,7 @@ func EditRelease(ctx *context.Context) { ctx.Data["content"] = rel.Note ctx.Data["prerelease"] = rel.IsPrerelease ctx.Data["IsDraft"] = rel.IsDraft + ctx.Data["ReleaseStream"] = licenses_model.GetReleaseStream(ctx, rel.ID) rel.Repo = ctx.Repo.Repository if err := rel.LoadAttributes(ctx); err != nil { @@ -660,6 +677,12 @@ func EditReleasePost(ctx *context.Context) { ctx.ServerError("UpdateRelease", err) return } + // Save manual stream assignment. + if streamName := form.UpdateStream; streamName != "" { + _ = licenses_model.SetReleaseStream(ctx, rel.ID, rel.RepoID, streamName) + } else { + _ = licenses_model.DeleteReleaseStream(ctx, rel.ID) + } ctx.Redirect(ctx.Repo.RepoLink + "/releases") } diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index f8b4cce9c5..7216850633 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -670,8 +670,9 @@ type NewReleaseForm struct { Draft bool TagOnly bool Prerelease bool - AddTagMsg bool - Files []string + AddTagMsg bool + Files []string + UpdateStream string } // Validate validates the fields diff --git a/services/updateserver/dolibarr.go b/services/updateserver/dolibarr.go index c31a517275..d93c68a6ac 100644 --- a/services/updateserver/dolibarr.go +++ b/services/updateserver/dolibarr.go @@ -67,7 +67,7 @@ func GenerateDolibarrJSON(ctx context.Context, repo *repo_model.Repository, allo if rel.IsDraft || rel.IsTag { continue } - ch := licenses.MatchStreamFromTag(rel.TagName, rel.IsPrerelease, streams) + ch := licenses.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams) existing, ok := bestByChannel[ch] if !ok || rel.CreatedUnix > existing.CreatedUnix { bestByChannel[ch] = rel @@ -108,6 +108,12 @@ func GenerateDolibarrJSON(ctx context.Context, repo *repo_model.Repository, allo } version := extractVersion(rel.TagName) + if version == "" || isStreamName(rel.TagName, streams) { + version = extractVersion(rel.Title) + } + if version == "" { + version = rel.TagName + } suffix := stream.Suffix if suffix == "" { suffix = channelSuffix(ch) diff --git a/services/updateserver/joomla.go b/services/updateserver/joomla.go index 808f644887..2513b7bc47 100644 --- a/services/updateserver/joomla.go +++ b/services/updateserver/joomla.go @@ -8,6 +8,7 @@ import ( "encoding/xml" "fmt" "io" + "regexp" "strings" "time" @@ -107,6 +108,17 @@ func channelFromTag(tagName string, isPrerelease bool) string { } } +// isStreamName checks if a string matches any stream name (indicating the tag +// is a stream name, not a version number). +func isStreamName(s string, streams []licenses.StreamDef) bool { + for _, st := range streams { + if strings.EqualFold(st.Name, s) { + return true + } + } + return false +} + // joomlaTagName maps internal stream names to Joomla-standard tag values. // Joomla recognizes: dev, alpha, beta, rc, stable. func joomlaTagName(channel string) string { @@ -215,7 +227,7 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require if rel.IsDraft || rel.IsTag { continue } - ch := licenses.MatchStreamFromTag(rel.TagName, rel.IsPrerelease, streams) + ch := licenses.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams) existing, ok := bestByChannel[ch] if !ok || rel.CreatedUnix > existing.CreatedUnix { bestByChannel[ch] = rel @@ -273,6 +285,14 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require } version := extractVersion(rel.TagName) + // If the tag is a stream name (not a version), try the release title instead. + if version == "" || isStreamName(rel.TagName, streams) { + version = extractVersion(rel.Title) + } + // Last resort: use the tag name as-is. + if version == "" { + version = rel.TagName + } suffix := stream.Suffix if suffix == "" { suffix = channelSuffix(ch) // fallback for Joomla defaults @@ -340,20 +360,35 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require return append([]byte(xml.Header), output...), nil } -// extractVersion strips common tag prefixes (v, release-, etc.) to get the version. -func extractVersion(tagName string) string { - v := tagName +// versionRegex matches semantic version patterns like 1.0.0, 02.29.04, etc. +var versionRegex = regexp.MustCompile(`(\d+\.\d+(?:\.\d+)?)`) + +// extractVersion finds a version number from a tag name or release title. +// Tries: (1) strip common prefixes for version-style tags, (2) regex match for embedded versions. +func extractVersion(s string) string { + // Try prefix stripping first (works for "v1.0.0", "release-1.0.0"). + v := s v = strings.TrimPrefix(v, "v") v = strings.TrimPrefix(v, "release-") v = strings.TrimPrefix(v, "release/") - // Strip channel suffixes to get base version. + // Strip channel suffixes. for _, suffix := range []string{"-dev", "-alpha", "-beta", "-rc", "-development", "-release-candidate"} { if idx := strings.Index(strings.ToLower(v), suffix); idx > 0 { v = v[:idx] break } } - return v + // If result looks like a version (starts with digit), use it. + if len(v) > 0 && v[0] >= '0' && v[0] <= '9' { + return strings.TrimSpace(v) + } + + // Fallback: extract version pattern from anywhere in the string. + if m := versionRegex.FindString(s); m != "" { + return m + } + + return "" } // channelSuffix returns the version suffix for a channel. diff --git a/services/updateserver/wordpress.go b/services/updateserver/wordpress.go index 9fb469252d..cf245ab068 100644 --- a/services/updateserver/wordpress.go +++ b/services/updateserver/wordpress.go @@ -96,7 +96,7 @@ func GenerateWordPressJSON(ctx context.Context, repo *repo_model.Repository, lic if rel.IsDraft || rel.IsTag { continue } - ch := licenses.MatchStreamFromTag(rel.TagName, rel.IsPrerelease, streams) + ch := licenses.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams) if ch == "stable" { if latestStable == nil || rel.CreatedUnix > latestStable.CreatedUnix { latestStable = rel @@ -139,6 +139,12 @@ func GenerateWordPressJSON(ctx context.Context, repo *repo_model.Repository, lic } version := extractVersion(latestStable.TagName) + if version == "" || isStreamName(latestStable.TagName, streams) { + version = extractVersion(latestStable.Title) + } + if version == "" { + version = latestStable.TagName + } lastUpdated := time.Unix(int64(latestStable.CreatedUnix), 0).Format("2006-01-02 3:04pm MST") // Build sections from release notes. @@ -178,7 +184,7 @@ func buildWordPressChangelog(releases []*repo_model.Release, streams []licenses. if rel.IsDraft || rel.IsTag || rel.Note == "" { continue } - ch := licenses.MatchStreamFromTag(rel.TagName, rel.IsPrerelease, streams) + ch := licenses.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams) if ch != "stable" { continue } diff --git a/templates/org/licenses.tmpl b/templates/org/licenses.tmpl index 27fbc82a85..ef0ae1da53 100644 --- a/templates/org/licenses.tmpl +++ b/templates/org/licenses.tmpl @@ -149,8 +149,10 @@
-
- +
+
+ +
{{if .SearchQuery}}{{ctx.Locale.Tr "repo.licenses.clear_search"}}{{end}}
diff --git a/templates/repo/licenses.tmpl b/templates/repo/licenses.tmpl index a0b7a710c6..4e4e028d43 100644 --- a/templates/repo/licenses.tmpl +++ b/templates/repo/licenses.tmpl @@ -155,8 +155,10 @@
-
- +
+
+ +
{{if .SearchQuery}}{{ctx.Locale.Tr "repo.licenses.clear_search"}}{{end}}
diff --git a/templates/repo/release/new.tmpl b/templates/repo/release/new.tmpl index cc73bdf13c..d23e103c3d 100644 --- a/templates/repo/release/new.tmpl +++ b/templates/repo/release/new.tmpl @@ -117,6 +117,19 @@
{{ctx.Locale.Tr "repo.release.prerelease_helper"}}
+ {{if .LicensingEnabled}} +
+ + +
{{ctx.Locale.Tr "repo.release.update_stream_help"}}
+
+ {{end}} +
{{if .PageIsEditRelease}}