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
6 changes: 6 additions & 0 deletions internal/app/azldev/cmds/component/preparesources.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package component
import (
"errors"
"fmt"
"log/slog"

"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev"
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components"
Expand Down Expand Up @@ -117,6 +118,11 @@ func PrepareComponentSources(env *azldev.Env, options *PrepareSourcesOptions) er
return err
}

if options.SkipOverlays && options.WithGitRepo {
slog.Warn("--with-git has no effect when --skip-overlays is set; " +
"synthetic history requires overlays to be applied")
}

var preparerOpts []sources.PreparerOption
if options.WithGitRepo {
preparerOpts = append(preparerOpts, sources.WithGitRepo())
Expand Down
177 changes: 177 additions & 0 deletions internal/app/azldev/core/sources/release.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

package sources

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

"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"
)

// autoreleasePattern matches the %autorelease macro invocation in a Release tag value.
// This covers both the bare form (%autorelease) and the braced form (%{autorelease}).
var autoreleasePattern = regexp.MustCompile(`%(\{autorelease\}|autorelease($|\s))`)

// staticReleasePattern matches a leading integer in a static Release tag value,
// followed by an optional suffix (e.g. "%{?dist}").
var staticReleasePattern = regexp.MustCompile(`^(\d+)(.*)$`)

// GetReleaseTagValue reads the Release tag value from the spec file at specPath.
// It returns the raw value string as written in the spec (e.g. "1%{?dist}" or "%autorelease").
// Returns [spec.ErrNoSuchTag] if no Release tag is found.
func GetReleaseTagValue(fs opctx.FS, specPath string) (string, error) {
specFile, err := fs.Open(specPath)
if err != nil {
return "", fmt.Errorf("failed to open spec %#q:\n%w", specPath, err)
}
defer specFile.Close()

openedSpec, err := spec.OpenSpec(specFile)
if err != nil {
return "", fmt.Errorf("failed to parse spec %#q:\n%w", specPath, err)
}

var releaseValue string

err = openedSpec.VisitTagsPackage("", func(tagLine *spec.TagLine, _ *spec.Context) error {
if strings.EqualFold(tagLine.Tag, "Release") {
releaseValue = tagLine.Value
}

return nil
})
if err != nil {
return "", fmt.Errorf("failed to visit tags in spec %#q:\n%w", specPath, err)
}

if releaseValue == "" {
return "", fmt.Errorf("release tag not found in spec %#q:\n%w", specPath, spec.ErrNoSuchTag)
}

return releaseValue, nil
}

// ReleaseUsesAutorelease reports whether the given Release tag value uses the
// %autorelease macro (either bare or braced form).
func ReleaseUsesAutorelease(releaseValue string) bool {
return autoreleasePattern.MatchString(releaseValue)
}

// BumpStaticRelease increments the leading integer in a static Release tag value
// by the given commit count.
func BumpStaticRelease(releaseValue string, commitCount int) (string, error) {
matches := staticReleasePattern.FindStringSubmatch(releaseValue)
if matches == nil {
return "", fmt.Errorf("release value %#q does not start with an integer", releaseValue)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We know we'll find specs that don't use autorelease and don't have a standard integer followed by %{?dist}.

Example: https://src.fedoraproject.org/rpms/kernel/blob/rawhide/f/kernel.spec

For these, we'll want some way to avoid this being a fatal error. From Dan's "3 categories", this would be that 3rd case.

For those it would be okay for us to manually manage their Release values, but we'd need a way to do so. We can use an overlay to set the Release tag's value, but would then also need a way to disable this logic from running (and failing).

}

currentRelease, err := strconv.Atoi(matches[1])
if err != nil {
return "", fmt.Errorf("failed to parse release number from %#q:\n%w", releaseValue, err)
}

newRelease := currentRelease + commitCount
suffix := matches[2]

return fmt.Sprintf("%d%s", newRelease, suffix), nil
}

// HasUserReleaseOverlay reports whether the given overlay list contains an overlay
// that explicitly sets or updates the Release tag. This is used to determine whether
// a user has configured the component to handle a non-standard Release value
// (e.g. one using a custom macro like %{pkg_release}).
func HasUserReleaseOverlay(overlays []projectconfig.ComponentOverlay) bool {
for _, overlay := range overlays {
if !strings.EqualFold(overlay.Tag, "Release") {
continue
}

if overlay.Type == projectconfig.ComponentOverlaySetSpecTag ||
overlay.Type == projectconfig.ComponentOverlayUpdateSpecTag {
return true
}
}

return false
}

// 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.
//
// When the spec uses %autorelease, this function is a no-op because rpmautospec
// already resolves the release number from git history.
//
// When the Release tag uses a non-standard value (not %autorelease and not a leading
// integer, e.g. %{pkg_release}), the component must define an explicit overlay that
// sets the Release tag. If no such overlay exists, an error is returned.
func (p *sourcePreparerImpl) tryBumpStaticRelease(
component components.Component,
sourcesDirPath string,
commitCount int,
) error {
specPath, err := p.resolveSpecPath(component, sourcesDirPath)
if err != nil {
return err
}

releaseValue, err := GetReleaseTagValue(p.fs, specPath)
if err != nil {
return fmt.Errorf("failed to read Release tag for component %#q:\n%w",
component.GetName(), err)
}

if ReleaseUsesAutorelease(releaseValue) {
slog.Debug("Spec uses %%autorelease; skipping static release bump",
"component", component.GetName())

return nil
}

// Skip static release bump if the user has defined an explicit overlay for the Release tag.
if HasUserReleaseOverlay(component.GetConfig().Overlays) {
slog.Debug("Component has an explicit Release overlay; skipping static release bump",
"component", component.GetName())

return nil
}

newRelease, err := BumpStaticRelease(releaseValue, commitCount)
if err != nil {
// The Release tag does not start with an integer (e.g. %{pkg_release})
// and the user did not provide an explicit overlay to set it.
return fmt.Errorf(
"component %#q has a non-standard Release tag value %#q that cannot be auto-bumped; "+
"add a \"spec-set-tag\" overlay for the Release tag in the component configuration",
component.GetName(), releaseValue)
}

slog.Info("Bumping static release",
"component", component.GetName(),
"oldRelease", releaseValue,
"newRelease", newRelease,
"commitCount", commitCount)

overlay := projectconfig.ComponentOverlay{
Type: projectconfig.ComponentOverlayUpdateSpecTag,
Tag: "Release",
Value: newRelease,
}

if err := ApplySpecOverlayToFileInPlace(p.fs, overlay, specPath); err != nil {
return fmt.Errorf("failed to apply release bump overlay for component %#q:\n%w",
component.GetName(), err)
}

return nil
}
131 changes: 131 additions & 0 deletions internal/app/azldev/core/sources/release_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

package sources_test

import (
"testing"

"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/sources"
"github.com/microsoft/azure-linux-dev-tools/internal/global/testctx"
"github.com/microsoft/azure-linux-dev-tools/internal/projectconfig"
"github.com/microsoft/azure-linux-dev-tools/internal/rpm/spec"
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestReleaseUsesAutorelease(t *testing.T) {
for _, testCase := range []struct {
value string
expected bool
}{
{"%autorelease", true},
{"%{autorelease}", true},
{"1", false},
{"1%{?dist}", false},
{"3%{?dist}.1", false},
{"", false},
} {
t.Run(testCase.value, func(t *testing.T) {
assert.Equal(t, testCase.expected, sources.ReleaseUsesAutorelease(testCase.value))
})
}
}

func TestBumpStaticRelease(t *testing.T) {
for _, testCase := range []struct {
name, value string
commits int
expected string
wantErr bool
}{
{"simple integer", "1", 3, "4", false},
{"with dist tag", "1%{?dist}", 2, "3%{?dist}", false},
{"larger base", "10%{?dist}", 5, "15%{?dist}", false},
{"single commit", "1%{?dist}", 1, "2%{?dist}", false},
{"no leading int", "%{?dist}", 1, "", true},
{"empty string", "", 1, "", true},
} {
t.Run(testCase.name, func(t *testing.T) {
result, err := sources.BumpStaticRelease(testCase.value, testCase.commits)
if testCase.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, testCase.expected, result)
}
})
}
}

func TestGetReleaseTagValue(t *testing.T) {
makeSpec := func(release string) string {
return "Name: test-package\nVersion: 1.0.0\nRelease: " + release + "\nSummary: Test\n"
}

for _, testCase := range []struct {
name, specContent, expected string
wantErr bool
}{
{"static with dist", makeSpec("1%{?dist}"), "1%{?dist}", false},
{"autorelease", makeSpec("%autorelease"), "%autorelease", false},
{"braced autorelease", makeSpec("%{autorelease}"), "%{autorelease}", false},
{"no release tag", "Name: test-package\nVersion: 1.0.0\nSummary: Test\n", "", true},
} {
t.Run(testCase.name, func(t *testing.T) {
ctx := testctx.NewCtx()
specPath := "/test.spec"

err := fileutils.WriteFile(ctx.FS(), specPath, []byte(testCase.specContent), 0o644)
require.NoError(t, err)

result, err := sources.GetReleaseTagValue(ctx.FS(), specPath)
if testCase.wantErr {
require.ErrorIs(t, err, spec.ErrNoSuchTag)
} else {
require.NoError(t, err)
assert.Equal(t, testCase.expected, result)
}
})
}
}

func TestGetReleaseTagValue_FileNotFound(t *testing.T) {
ctx := testctx.NewCtx()
_, err := sources.GetReleaseTagValue(ctx.FS(), "/nonexistent.spec")
require.Error(t, err)
}

func TestHasUserReleaseOverlay(t *testing.T) {
for _, testCase := range []struct {
name string
overlays []projectconfig.ComponentOverlay
expected bool
}{
{"no overlays", nil, false},
{"unrelated tag", []projectconfig.ComponentOverlay{
{Type: projectconfig.ComponentOverlaySetSpecTag, Tag: "Version", Value: "1.0"},
}, false},
{"unsupported overlay type", []projectconfig.ComponentOverlay{
{Type: projectconfig.ComponentOverlayAddSpecTag, Tag: "Release", Value: "1%{?dist}"},
}, false},
{"spec-set-tag", []projectconfig.ComponentOverlay{
{Type: projectconfig.ComponentOverlaySetSpecTag, Tag: "Release", Value: "1%{?dist}"},
}, true},
{"spec-update-tag", []projectconfig.ComponentOverlay{
{Type: projectconfig.ComponentOverlayUpdateSpecTag, Tag: "Release", Value: "2%{?dist}"},
}, true},
{"case insensitive", []projectconfig.ComponentOverlay{
{Type: projectconfig.ComponentOverlaySetSpecTag, Tag: "release", Value: "1%{?dist}"},
}, true},
{"mixed overlays", []projectconfig.ComponentOverlay{
{Type: projectconfig.ComponentOverlaySetSpecTag, Tag: "BuildRequires", Value: "gcc"},
{Type: projectconfig.ComponentOverlaySetSpecTag, Tag: "Release", Value: "5%{?dist}"},
}, true},
} {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.expected, sources.HasUserReleaseOverlay(testCase.overlays))
})
}
}
23 changes: 13 additions & 10 deletions internal/app/azldev/core/sources/sourceprep.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,13 +152,13 @@ func (p *sourcePreparerImpl) PrepareSources(
if err != nil {
return err
}
}

// Record the changes as synthetic git history when dist-git creation is enabled.
if p.withGitRepo {
if err := p.trySyntheticHistory(component, outputDir); err != nil {
return fmt.Errorf("failed to generate synthetic history for component %#q:\n%w",
component.GetName(), err)
// Record the changes as synthetic git history when dist-git creation is enabled.
if p.withGitRepo {
if err := p.trySyntheticHistory(component, outputDir); err != nil {
return fmt.Errorf("failed to generate synthetic history for component %#q:\n%w",
component.GetName(), err)
}
}
}

Expand Down Expand Up @@ -310,10 +310,13 @@ func (p *sourcePreparerImpl) trySyntheticHistory(
return nil
}

// Check for an existing git repository in the sources directory.
// Use os.Stat rather than p.fs because go-git's PlainInit/PlainOpen always
// operate on the real OS filesystem — the check must use the same source of
// truth to avoid disagreement when p.fs is an in-memory FS (e.g. unit tests).
// Adjust the Release tag before staging changes. See [tryBumpStaticRelease]
// for the handling of %autorelease, static integers, and non-standard values.
if err := p.tryBumpStaticRelease(component, sourcesDirPath, len(commits)); err != nil {
return fmt.Errorf("failed to apply release bump:\n%w", err)
}

// Use os.Stat (not p.fs) because go-git always operates on the real filesystem.
gitDirPath := filepath.Join(sourcesDirPath, ".git")

_, statErr := os.Stat(gitDirPath)
Expand Down
8 changes: 4 additions & 4 deletions internal/app/azldev/core/sources/synthistory.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,6 @@ func buildSyntheticCommits(
return nil, fmt.Errorf("failed to find Affects commits for component %#q:\n%w", componentName, err)
}

slog.Info("Found commits affecting component",
"component", componentName,
"commitCount", len(affectsCommits))

if len(affectsCommits) == 0 {
slog.Info("No commits with Affects marker found; "+
"creating default commit",
Expand All @@ -162,6 +158,10 @@ func buildSyntheticCommits(
return []CommitMetadata{
defaultOverlayCommit(projectRepo, componentName),
}, nil
} else {
slog.Info("Found commits affecting component",
"component", componentName,
"commitCount", len(affectsCommits))
}

return affectsCommits, nil
Expand Down
Loading