Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 166 additions & 5 deletions internal/app/azldev/core/sources/release.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,26 @@ package sources

import (
"fmt"
"io"
"log/slog"
"regexp"
"strconv"
"strings"

"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components"
"github.com/microsoft/azure-linux-dev-tools/internal/global/opctx"
"github.com/microsoft/azure-linux-dev-tools/internal/projectconfig"
"github.com/microsoft/azure-linux-dev-tools/internal/rpm/spec"
)

// commitResolver abstracts the ability to look up a commit by hash.
// This is satisfied by [*gogit.Repository] and can be replaced in tests.
type commitResolver interface {
CommitObject(hash plumbing.Hash) (*object.Commit, error)
}

// autoreleasePattern matches the %autorelease macro invocation in a Release tag value.
// This covers:
// - bare form: %autorelease
Expand Down Expand Up @@ -72,6 +81,145 @@ func ReleaseUsesAutorelease(releaseValue string) bool {
return autoreleasePattern.MatchString(releaseValue)
}

// GetVersionTagFromReader reads the Version tag value from a spec parsed from an [io.Reader].
// Returns the raw value string (e.g. "1.0.0" or "%{base_version}").
// Returns [spec.ErrNoSuchTag] if no Version tag is found.
func GetVersionTagFromReader(reader io.Reader) (string, error) {
openedSpec, err := spec.OpenSpec(reader)
if err != nil {
return "", fmt.Errorf("failed to parse spec:\n%w", err)
}

var versionValue string

err = openedSpec.VisitTagsPackage("", func(tagLine *spec.TagLine, _ *spec.Context) error {
if strings.EqualFold(tagLine.Tag, "Version") {
versionValue = tagLine.Value
Comment thread
Tonisal-byte marked this conversation as resolved.
}

return nil
})
Comment thread
Tonisal-byte marked this conversation as resolved.
if err != nil {
return "", fmt.Errorf("failed to visit tags:\n%w", err)
}

if versionValue == "" {
return "", fmt.Errorf("version tag not found:\n%w", spec.ErrNoSuchTag)
}

return versionValue, nil
}

// getVersionAtUpstreamCommit reads the Version tag from a component's spec file
// at a specific upstream commit in the dist-git repository. The spec file is
// located by name (e.g. "package.spec") within the commit's tree.
func getVersionAtUpstreamCommit(
resolver commitResolver,
commitHash string,
specFileName string,
) (string, error) {
commitObj, err := resolver.CommitObject(plumbing.NewHash(commitHash))
if err != nil {
return "", fmt.Errorf("failed to get commit %#q:\n%w", commitHash, err)
}

tree, err := commitObj.Tree()
if err != nil {
return "", fmt.Errorf("failed to get tree for commit %#q:\n%w", commitHash, err)
}

file, err := tree.File(specFileName)
if err != nil {
return "", fmt.Errorf("spec file %#q not found at commit %#q:\n%w", specFileName, commitHash, err)
}

reader, err := file.Reader()
if err != nil {
return "", fmt.Errorf("failed to read spec file %#q at commit %#q:\n%w", specFileName, commitHash, err)
}
defer reader.Close()

return GetVersionTagFromReader(reader)
}

// CountCommitsSinceVersionChange counts how many [FingerprintChange] entries
// since the last Version tag change should contribute to a static Release bump.
//
// Unresolvable upstream commits are treated as version boundaries. A nil
// resolver (local components) counts all changes. Returns an error if the
// Version tag contains unexpanded RPM macros (e.g. "%{base_version}").
func CountCommitsSinceVersionChange(
resolver commitResolver,
specFileName string,
changes []FingerprintChange,
) (int, error) {
if len(changes) == 0 {
return 0, nil
}

if resolver == nil {
return len(changes), nil
}

// Walk changes newest-to-oldest, resolving the Version tag at each unique
// upstream commit. Stop as soon as a version change is detected.
versionCache := make(map[string]string)
count := 0

latestVersion := ""

for idx := len(changes) - 1; idx >= 0; idx-- {
hash := changes[idx].UpstreamCommit

Comment thread
Tonisal-byte marked this conversation as resolved.
if hash == "" {
// Local or synthetic changes are not tied to a resolvable upstream
// commit. Count them all since there's no version history to consult.
count++

continue
}

version, ok := versionCache[hash]
if !ok {
var err error

version, err = getVersionAtUpstreamCommit(resolver, hash, specFileName)
if err != nil {
slog.Warn("Failed to read Version tag at upstream commit; treating as version boundary",
"commit", hash, "error", err)

break
}
Comment thread
Tonisal-byte marked this conversation as resolved.
Comment thread
Tonisal-byte marked this conversation as resolved.

if strings.Contains(version, "%") {
return 0, fmt.Errorf(
"version tag at commit %#q contains unexpanded macro %#q; "+
"version-change detection requires macro expansion which is not available here",
hash, version)
}

versionCache[hash] = version
}

if latestVersion == "" {
latestVersion = version
}

if version != latestVersion {
break
}

count++
}

slog.Debug("Computed version-aware release bump count",
"latestVersion", latestVersion,
"totalChanges", len(changes),
"sinceVersionChange", count)

return count, nil
}

// BumpStaticRelease increments the leading integer in a static Release tag value
// by the given commit count.
func BumpStaticRelease(releaseValue string, commitCount int) (string, error) {
Expand All @@ -92,10 +240,8 @@ func BumpStaticRelease(releaseValue string, commitCount int) (string, error) {
}

// tryBumpStaticRelease checks whether the component's spec uses %autorelease.
// If not, it bumps the static Release tag by commitCount and applies the change
// as an overlay to the spec file in-place. This ensures that components with static
// release numbers get deterministic version bumps matching the number of synthetic
// commits applied from the project repository.
// If not, it computes a version-aware bump count via [CountCommitsSinceVersionChange]
// and applies the change as an overlay to the spec file in-place.
//
// When the component's release calculation is "manual", this function is a no-op.
//
Expand All @@ -110,7 +256,8 @@ func BumpStaticRelease(releaseValue string, commitCount int) (string, error) {
func (p *sourcePreparerImpl) tryBumpStaticRelease(
component components.Component,
sourcesDirPath string,
commitCount int,
repo commitResolver,
changes []FingerprintChange,
) error {
if component.GetConfig().Release.Calculation == projectconfig.ReleaseCalculationManual {
slog.Debug("Component uses manual release calculation; skipping static release bump",
Expand All @@ -137,6 +284,20 @@ func (p *sourcePreparerImpl) tryBumpStaticRelease(
return nil
}

// Only compute the version-aware commit count after confirming the spec
// uses a static release, to avoid unnecessary git tree traversals for
// components that use %autorelease or manual mode.
specFileName := component.GetName() + ".spec"

commitCount, err := CountCommitsSinceVersionChange(repo, specFileName, changes)
if err != nil {
return fmt.Errorf(
"component %#q has a Version tag that cannot be resolved for auto-release calculation; "+
"set 'release.calculation = \"manual\"' in the component configuration "+
"and add a \"spec-set-tag\" overlay for the Release tag if needed:\n%w",
component.GetName(), err)
}

newRelease, err := BumpStaticRelease(releaseValue, commitCount)
if err != nil {
// The Release tag does not start with an integer (e.g. %{pkg_release})
Expand Down
14 changes: 8 additions & 6 deletions internal/app/azldev/core/sources/release_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func TestTryBumpStaticRelease_ManualSkips(t *testing.T) {
})

// No spec file needed — should skip before reading anything.
err := preparer.tryBumpStaticRelease(comp, testSourcesDir, 3)
err := preparer.tryBumpStaticRelease(comp, testSourcesDir, nil, nil)
require.NoError(t, err)
}

Expand All @@ -76,7 +76,7 @@ func TestTryBumpStaticRelease_AutoreleaseSkips(t *testing.T) {
},
})

err := preparer.tryBumpStaticRelease(comp, filepath.Join(testSourcesDir, "test-pkg"), 3)
err := preparer.tryBumpStaticRelease(comp, filepath.Join(testSourcesDir, "test-pkg"), nil, nil)
require.NoError(t, err)
}

Expand All @@ -93,14 +93,16 @@ func TestTryBumpStaticRelease_StaticBumps(t *testing.T) {
},
})

err := preparer.tryBumpStaticRelease(comp, filepath.Join(testSourcesDir, "test-pkg"), 3)
// Pass nil repo so CountCommitsSinceVersionChange falls back to len(changes).
changes := make([]FingerprintChange, 5)
err := preparer.tryBumpStaticRelease(comp, filepath.Join(testSourcesDir, "test-pkg"), nil, changes)
require.NoError(t, err)

// Verify the spec was updated.
specPath := filepath.Join(testSourcesDir, "test-pkg", "test-pkg.spec")
content, err := fileutils.ReadFile(memFS, specPath)
require.NoError(t, err)
assert.Contains(t, string(content), "Release: 4%{?dist}")
assert.Contains(t, string(content), "Release: 6%{?dist}")
}

func TestTryBumpStaticRelease_NonStandardErrorsWithoutManual(t *testing.T) {
Expand All @@ -116,7 +118,7 @@ func TestTryBumpStaticRelease_NonStandardErrorsWithoutManual(t *testing.T) {
},
})

err := preparer.tryBumpStaticRelease(comp, filepath.Join(testSourcesDir, "kernel"), 3)
err := preparer.tryBumpStaticRelease(comp, filepath.Join(testSourcesDir, "kernel"), nil, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot be auto-bumped")
assert.Contains(t, err.Error(), "release.calculation")
Expand All @@ -135,6 +137,6 @@ func TestTryBumpStaticRelease_NonStandardSucceedsWithManual(t *testing.T) {
},
})

err := preparer.tryBumpStaticRelease(comp, filepath.Join(testSourcesDir, "kernel"), 3)
err := preparer.tryBumpStaticRelease(comp, filepath.Join(testSourcesDir, "kernel"), nil, nil)
require.NoError(t, err)
}
Loading
Loading