From 72ca092ab06e877ec7db8d3726b9aa356b68d109 Mon Sep 17 00:00:00 2001 From: Antonio Salinas Date: Tue, 31 Mar 2026 23:20:52 +0000 Subject: [PATCH 1/4] feat: static release bumped during dist-git generation --- .../app/azldev/core/sources/releaseoverlay.go | 146 ++++++++++++++++++ .../core/sources/releaseoverlay_test.go | 97 ++++++++++++ .../app/azldev/core/sources/sourceprep.go | 9 ++ 3 files changed, 252 insertions(+) create mode 100644 internal/app/azldev/core/sources/releaseoverlay.go create mode 100644 internal/app/azldev/core/sources/releaseoverlay_test.go diff --git a/internal/app/azldev/core/sources/releaseoverlay.go b/internal/app/azldev/core/sources/releaseoverlay.go new file mode 100644 index 0000000..682cfa9 --- /dev/null +++ b/internal/app/azldev/core/sources/releaseoverlay.go @@ -0,0 +1,146 @@ +// 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\}?`) + +// 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 + found bool + ) + + err = openedSpec.VisitTagsPackage("", func(tagLine *spec.TagLine, _ *spec.Context) error { + if strings.EqualFold(tagLine.Tag, "Release") { + releaseValue = tagLine.Value + found = true + } + + return nil + }) + if err != nil { + return "", fmt.Errorf("failed to visit tags in spec %#q:\n%w", specPath, err) + } + + if !found { + 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 +} + +// 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. +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 + } + + newRelease, err := BumpStaticRelease(releaseValue, commitCount) + if err != nil { + return fmt.Errorf("failed to bump release for component %#q:\n%w", + component.GetName(), err) + } + + 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/releaseoverlay_test.go b/internal/app/azldev/core/sources/releaseoverlay_test.go new file mode 100644 index 0000000..c9cd81b --- /dev/null +++ b/internal/app/azldev/core/sources/releaseoverlay_test.go @@ -0,0 +1,97 @@ +// 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/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) +} diff --git a/internal/app/azldev/core/sources/sourceprep.go b/internal/app/azldev/core/sources/sourceprep.go index 79c99a7..44a1334 100644 --- a/internal/app/azldev/core/sources/sourceprep.go +++ b/internal/app/azldev/core/sources/sourceprep.go @@ -310,6 +310,15 @@ func (p *sourcePreparerImpl) trySyntheticHistory( return nil } + // Bump the static Release tag before staging changes. For specs using + // %autorelease this is a no-op; rpmautospec resolves releases from git + // history automatically. For static releases the leading integer is + // incremented by the number of synthetic commits so that every project + // commit that affects the component produces a unique release number. + if err := p.tryBumpStaticRelease(component, sourcesDirPath, len(commits)); err != nil { + return fmt.Errorf("failed to apply release bump:\n%w", err) + } + // 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 From ee148e322ad235f1e2f4c25878e56f7d1aad3ccf Mon Sep 17 00:00:00 2001 From: Antonio Salinas Date: Wed, 1 Apr 2026 17:45:13 +0000 Subject: [PATCH 2/4] fix: release regex fix --- internal/app/azldev/core/sources/releaseoverlay.go | 10 +++------- internal/app/azldev/core/sources/synthistory.go | 8 ++++---- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/internal/app/azldev/core/sources/releaseoverlay.go b/internal/app/azldev/core/sources/releaseoverlay.go index 682cfa9..ae900c8 100644 --- a/internal/app/azldev/core/sources/releaseoverlay.go +++ b/internal/app/azldev/core/sources/releaseoverlay.go @@ -18,7 +18,7 @@ import ( // 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\}?`) +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}"). @@ -39,15 +39,11 @@ func GetReleaseTagValue(fs opctx.FS, specPath string) (string, error) { return "", fmt.Errorf("failed to parse spec %#q:\n%w", specPath, err) } - var ( - releaseValue string - found bool - ) + var releaseValue string err = openedSpec.VisitTagsPackage("", func(tagLine *spec.TagLine, _ *spec.Context) error { if strings.EqualFold(tagLine.Tag, "Release") { releaseValue = tagLine.Value - found = true } return nil @@ -56,7 +52,7 @@ func GetReleaseTagValue(fs opctx.FS, specPath string) (string, error) { return "", fmt.Errorf("failed to visit tags in spec %#q:\n%w", specPath, err) } - if !found { + if releaseValue == "" { return "", fmt.Errorf("release tag not found in spec %#q:\n%w", specPath, spec.ErrNoSuchTag) } 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 From 04b5b96c7ec874a6a3a0b87cf859131a2e0ee00a Mon Sep 17 00:00:00 2001 From: Antonio Salinas Date: Wed, 1 Apr 2026 21:40:35 +0000 Subject: [PATCH 3/4] feat: require explicit overlay for non-standard release tag --- .../sources/{releaseoverlay.go => release.go} | 39 ++++++++++++++++++- ...releaseoverlay_test.go => release_test.go} | 34 ++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) rename internal/app/azldev/core/sources/{releaseoverlay.go => release.go} (74%) rename internal/app/azldev/core/sources/{releaseoverlay_test.go => release_test.go} (66%) diff --git a/internal/app/azldev/core/sources/releaseoverlay.go b/internal/app/azldev/core/sources/release.go similarity index 74% rename from internal/app/azldev/core/sources/releaseoverlay.go rename to internal/app/azldev/core/sources/release.go index ae900c8..de006a1 100644 --- a/internal/app/azldev/core/sources/releaseoverlay.go +++ b/internal/app/azldev/core/sources/release.go @@ -84,6 +84,25 @@ func BumpStaticRelease(releaseValue string, commitCount int) (string, error) { 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 @@ -92,6 +111,10 @@ func BumpStaticRelease(releaseValue string, commitCount int) (string, error) { // // 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, @@ -115,10 +138,22 @@ func (p *sourcePreparerImpl) tryBumpStaticRelease( 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 { - return fmt.Errorf("failed to bump release for component %#q:\n%w", - component.GetName(), err) + // 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", diff --git a/internal/app/azldev/core/sources/releaseoverlay_test.go b/internal/app/azldev/core/sources/release_test.go similarity index 66% rename from internal/app/azldev/core/sources/releaseoverlay_test.go rename to internal/app/azldev/core/sources/release_test.go index c9cd81b..2351689 100644 --- a/internal/app/azldev/core/sources/releaseoverlay_test.go +++ b/internal/app/azldev/core/sources/release_test.go @@ -8,6 +8,7 @@ import ( "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" @@ -95,3 +96,36 @@ func TestGetReleaseTagValue_FileNotFound(t *testing.T) { _, 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)) + }) + } +} From 4cb971d85a2a199b6a4b798fca306517de2ffd9a Mon Sep 17 00:00:00 2001 From: Antonio Salinas Date: Wed, 1 Apr 2026 22:38:21 +0000 Subject: [PATCH 4/4] fix: gate dist-git behind applyOverlays --- .../azldev/cmds/component/preparesources.go | 6 +++++ .../app/azldev/core/sources/sourceprep.go | 24 +++++++------------ 2 files changed, 15 insertions(+), 15 deletions(-) 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/sourceprep.go b/internal/app/azldev/core/sources/sourceprep.go index 44a1334..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,19 +310,13 @@ func (p *sourcePreparerImpl) trySyntheticHistory( return nil } - // Bump the static Release tag before staging changes. For specs using - // %autorelease this is a no-op; rpmautospec resolves releases from git - // history automatically. For static releases the leading integer is - // incremented by the number of synthetic commits so that every project - // commit that affects the component produces a unique release number. + // 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) } - // 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). + // Use os.Stat (not p.fs) because go-git always operates on the real filesystem. gitDirPath := filepath.Join(sourcesDirPath, ".git") _, statErr := os.Stat(gitDirPath)