diff --git a/internal/app/azldev/cmds/component/preparesources.go b/internal/app/azldev/cmds/component/preparesources.go index 6bff06b..1d2fbeb 100644 --- a/internal/app/azldev/cmds/component/preparesources.go +++ b/internal/app/azldev/cmds/component/preparesources.go @@ -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" @@ -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()) diff --git a/internal/app/azldev/core/sources/release.go b/internal/app/azldev/core/sources/release.go new file mode 100644 index 0000000..de006a1 --- /dev/null +++ b/internal/app/azldev/core/sources/release.go @@ -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) + } + + 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 +} diff --git a/internal/app/azldev/core/sources/release_test.go b/internal/app/azldev/core/sources/release_test.go new file mode 100644 index 0000000..2351689 --- /dev/null +++ b/internal/app/azldev/core/sources/release_test.go @@ -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)) + }) + } +} diff --git a/internal/app/azldev/core/sources/sourceprep.go b/internal/app/azldev/core/sources/sourceprep.go index 79c99a7..91c78ae 100644 --- a/internal/app/azldev/core/sources/sourceprep.go +++ b/internal/app/azldev/core/sources/sourceprep.go @@ -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) + } } } @@ -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) diff --git a/internal/app/azldev/core/sources/synthistory.go b/internal/app/azldev/core/sources/synthistory.go index 0c41f2e..b9c4922 100644 --- a/internal/app/azldev/core/sources/synthistory.go +++ b/internal/app/azldev/core/sources/synthistory.go @@ -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", @@ -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