registry/internal/service/versioning.go
adam jones 7b211a46d6
feat: implement server versioning approach with semantic versioning support (#296)
This implements the versioning approach agreed upon in issue #158:

## Key Changes

### Documentation
- Added comprehensive versioning guide (docs/versioning.md)
- Updated FAQ with versioning guidance and best practices 
- Enhanced schema description with SHOULD recommendations

### Schema Updates
- Added 255-character limit for version strings in schema.json
- Enhanced version field description with semantic versioning guidance

### Implementation 
- New isSemanticVersion() function for proper semver detection
- Enhanced compareSemanticVersions() with prerelease support
- Implemented compareVersions() strategy:
  * Both semver: use semantic comparison
  * Neither semver: use timestamp comparison
  * Mixed: semver versions always take precedence
- Updated publish logic to determine "latest" using new strategy
- Added version length validation (255 char max)

### Versioning Strategy
1. Version MUST be string up to 255 characters
2. SHOULD use semantic versioning for predictable ordering
3. SHOULD align with package versions to reduce confusion 
4. MAY use prerelease labels for registry-specific versions
5. Registry attempts semver parsing, falls back to timestamp ordering
6. Clients SHOULD follow same comparison logic

Resolves #158

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: adam jones <domdomegg@users.noreply.github.com>
2025-08-26 14:55:38 +01:00

86 lines
2.7 KiB
Go

package service
import (
"strings"
"time"
"golang.org/x/mod/semver"
)
// IsSemanticVersion checks if a version string follows semantic versioning format
// Uses the official golang.org/x/mod/semver package for validation
// Requires exactly three parts: major.minor.patch (optionally with prerelease/build)
func IsSemanticVersion(version string) bool {
// The semver package requires a "v" prefix, so add it for validation
versionWithV := ensureVPrefix(version)
if !semver.IsValid(versionWithV) {
return false
}
// Additional validation: require exactly three parts (major.minor.patch)
// Strip the v prefix and any prerelease/build metadata for counting parts
// This ensures semver compliance, because the default go module accepts invalid semvers :/
// (See https://pkg.go.dev/golang.org/x/mod/semver)
versionCore := strings.TrimPrefix(versionWithV, "v")
if idx := strings.Index(versionCore, "-"); idx != -1 {
versionCore = versionCore[:idx]
}
if idx := strings.Index(versionCore, "+"); idx != -1 {
versionCore = versionCore[:idx]
}
parts := strings.Split(versionCore, ".")
return len(parts) == 3
}
// ensureVPrefix adds a "v" prefix if not present
func ensureVPrefix(version string) string {
if !strings.HasPrefix(version, "v") {
return "v" + version
}
return version
}
// compareSemanticVersions compares two semantic version strings
// Uses the official golang.org/x/mod/semver package for comparison
// Returns:
//
// -1 if version1 < version2
// 0 if version1 == version2
// +1 if version1 > version2
func compareSemanticVersions(version1 string, version2 string) int {
// The semver package requires a "v" prefix, so add it for comparison
v1 := ensureVPrefix(version1)
v2 := ensureVPrefix(version2)
return semver.Compare(v1, v2)
}
// CompareVersions implements the versioning strategy agreed upon in the discussion:
// 1. If both versions are valid semver, use semantic version comparison
// 2. If neither are valid semver, use publication timestamp (return 0 to indicate equal for sorting)
// 3. If one is semver and one is not, the semver version is always considered higher
func CompareVersions(version1 string, version2 string, timestamp1 time.Time, timestamp2 time.Time) int {
isSemver1 := IsSemanticVersion(version1)
isSemver2 := IsSemanticVersion(version2)
if isSemver1 && isSemver2 {
// Both are semver - use semantic comparison
return compareSemanticVersions(version1, version2)
}
if !isSemver1 && !isSemver2 {
// Neither are semver - use timestamp comparison
if timestamp1.Before(timestamp2) {
return -1
} else if timestamp1.After(timestamp2) {
return 1
}
return 0
}
// One is semver, one is not - semver is always higher
if isSemver1 && !isSemver2 {
return 1
}
return -1
}