Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions docs/user/reference/config/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ A component definition tells azldev where to find the spec file, how to customiz
| Field | TOML Key | Type | Required | Description |
|-------|----------|------|----------|-------------|
| Spec source | `spec` | [SpecSource](#spec-source) | No | Where to find the spec file for this component. Inherited from distro defaults if not specified. |
| Release calculation | `release-calculation` | string | No | Controls how the Release tag is managed during rendering. `"auto"` (default) = auto-bump; `"manual"` = skip all automatic Release manipulation |
Comment thread
reubeno marked this conversation as resolved.
Outdated
| Overlays | `overlays` | array of [Overlay](overlays.md) | No | Modifications to apply to the spec and/or source files |
| Build config | `build` | [BuildConfig](#build-configuration) | No | Build-time options (macros, conditionals, check config) |
| Source files | `source-files` | array of [SourceFileReference](#source-file-references) | No | Additional source files to download for this component |
Expand Down
4 changes: 4 additions & 0 deletions internal/app/azldev/core/components/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,10 @@ func (r *Resolver) createComponentFromConfig(componentConfig *projectconfig.Comp
componentConfig.Name, err)
}

if componentConfig.ReleaseCalculation == "" {
componentConfig.ReleaseCalculation = projectconfig.ReleaseCalculationAuto
}
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

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

This mutates the caller-owned *componentconfig.ComponentConfig in-place. If the same struct pointer is reused elsewhere (e.g., cached config maps, shared test fixtures), this can introduce hard-to-track side effects. Prefer defaulting on a local copy before storing it in the resolved component (e.g., copy the struct, set defaults on the copy, and keep the input immutable).

Copilot uses AI. Check for mistakes.

return &resolvedComponent{
env: r.env,
config: *componentConfig,
Expand Down
7 changes: 6 additions & 1 deletion internal/app/azldev/core/components/resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,18 @@ func setupTestSpec(t *testing.T, testEnv *testutils.TestEnv, path string) projec
SourceType: projectconfig.SpecSourceTypeLocal,
Path: path,
},
ReleaseCalculation: projectconfig.ReleaseCalculationAuto,
}
}

// Constructs a test component's config, adds it to the test environment, and returns a copy of it.
func addTestComponentToConfig(t *testing.T, env *testutils.TestEnv) projectconfig.ComponentConfig {
t.Helper()

component := projectconfig.ComponentConfig{Name: "test-component"}
component := projectconfig.ComponentConfig{
Name: "test-component",
ReleaseCalculation: projectconfig.ReleaseCalculationAuto,
}

env.Config.Components[component.Name] = component

Expand Down Expand Up @@ -242,6 +246,7 @@ func TestFindAllComponents_MergesComponentPresentBySpecAndConfig(t *testing.T) {
SourceType: projectconfig.SpecSourceTypeLocal,
Path: testSpecPath,
},
ReleaseCalculation: projectconfig.ReleaseCalculationAuto,
}

// Find!
Expand Down
46 changes: 15 additions & 31 deletions internal/app/azldev/core/sources/release.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,42 +84,33 @@ 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
// release numbers get deterministic version bumps matching the number of synthetic
// commits applied from the project repository.
//
// When the component's release calculation is "manual", this function is a no-op.
//
// 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.
// integer, e.g. %{pkg_release}), the component must set release-calculation to
// "manual", and likely define an explicit overlay that sets the Release tag.
// If release-calculation is not set, an error is returned.
Comment thread
dmcilvaney marked this conversation as resolved.
Outdated
func (p *sourcePreparerImpl) tryBumpStaticRelease(
component components.Component,
sourcesDirPath string,
commitCount int,
) error {
if component.GetConfig().ReleaseCalculation == projectconfig.ReleaseCalculationManual {
slog.Debug("Component uses manual release calculation; skipping static release bump",
"component", component.GetName())

return nil
}
Comment thread
dmcilvaney marked this conversation as resolved.
Outdated

specPath, err := p.resolveSpecPath(component, sourcesDirPath)
if err != nil {
return err
Expand All @@ -138,21 +129,14 @@ 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 {
// 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.
// and the user did not set release-calculation to "manual".
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:\n%w",
"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(), releaseValue, err)
}
Comment thread
dmcilvaney marked this conversation as resolved.

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

package sources

import (
"path/filepath"
"testing"

"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components/components_testutils"
"github.com/microsoft/azure-linux-dev-tools/internal/projectconfig"
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms"
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)

const testSourcesDir = "/sources"

func newTestPreparer(memFS afero.Fs) *sourcePreparerImpl {
return &sourcePreparerImpl{
fs: memFS,
}
}

func writeTestSpec(t *testing.T, memFS afero.Fs, name, release string) {
t.Helper()

specDir := filepath.Join(testSourcesDir, name)
require.NoError(t, fileutils.MkdirAll(memFS, specDir))

specPath := filepath.Join(specDir, name+".spec")
content := []byte("Name: " + name + "\nVersion: 1.0.0\nRelease: " + release + "\nSummary: Test\nLicense: MIT\n")

require.NoError(t, fileutils.WriteFile(memFS, specPath, content, fileperms.PublicFile))
}

func mockComponent(
ctrl *gomock.Controller, name string, config *projectconfig.ComponentConfig,
) *components_testutils.MockComponent {
comp := components_testutils.NewMockComponent(ctrl)
comp.EXPECT().GetName().AnyTimes().Return(name)
comp.EXPECT().GetConfig().AnyTimes().Return(config)

return comp
}

func TestTryBumpStaticRelease_ManualSkips(t *testing.T) {
ctrl := gomock.NewController(t)
memFS := afero.NewMemMapFs()
preparer := newTestPreparer(memFS)

comp := mockComponent(ctrl, "kernel", &projectconfig.ComponentConfig{
ReleaseCalculation: projectconfig.ReleaseCalculationManual,
})

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

func TestTryBumpStaticRelease_AutoreleaseSkips(t *testing.T) {
ctrl := gomock.NewController(t)
memFS := afero.NewMemMapFs()
preparer := newTestPreparer(memFS)

writeTestSpec(t, memFS, "test-pkg", "%autorelease")

comp := mockComponent(ctrl, "test-pkg", &projectconfig.ComponentConfig{
ReleaseCalculation: projectconfig.ReleaseCalculationAuto,
})

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

func TestTryBumpStaticRelease_StaticBumps(t *testing.T) {
ctrl := gomock.NewController(t)
memFS := afero.NewMemMapFs()
preparer := newTestPreparer(memFS)

writeTestSpec(t, memFS, "test-pkg", "1%{?dist}")

comp := mockComponent(ctrl, "test-pkg", &projectconfig.ComponentConfig{
ReleaseCalculation: projectconfig.ReleaseCalculationAuto,
})

err := preparer.tryBumpStaticRelease(comp, filepath.Join(testSourcesDir, "test-pkg"), 3)
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}")
}

func TestTryBumpStaticRelease_NonStandardErrorsWithoutManual(t *testing.T) {
ctrl := gomock.NewController(t)
memFS := afero.NewMemMapFs()
preparer := newTestPreparer(memFS)

writeTestSpec(t, memFS, "kernel", "%{pkg_release}")

comp := mockComponent(ctrl, "kernel", &projectconfig.ComponentConfig{
ReleaseCalculation: projectconfig.ReleaseCalculationAuto,
})

err := preparer.tryBumpStaticRelease(comp, filepath.Join(testSourcesDir, "kernel"), 3)
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot be auto-bumped")
assert.Contains(t, err.Error(), "release-calculation")
}

func TestTryBumpStaticRelease_NonStandardSucceedsWithManual(t *testing.T) {
ctrl := gomock.NewController(t)
memFS := afero.NewMemMapFs()
preparer := newTestPreparer(memFS)

writeTestSpec(t, memFS, "kernel", "%{pkg_release}")

comp := mockComponent(ctrl, "kernel", &projectconfig.ComponentConfig{
ReleaseCalculation: projectconfig.ReleaseCalculationManual,
})

err := preparer.tryBumpStaticRelease(comp, filepath.Join(testSourcesDir, "kernel"), 3)
require.NoError(t, err)
}
34 changes: 0 additions & 34 deletions internal/app/azldev/core/sources/release_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ 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"
Expand Down Expand Up @@ -96,36 +95,3 @@ 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))
})
}
}
20 changes: 20 additions & 0 deletions internal/projectconfig/component.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,20 @@ func (g ComponentGroupConfig) WithAbsolutePaths(referenceDir string) ComponentGr
return result
}

// ReleaseCalculation controls how the Release tag is managed during rendering.
type ReleaseCalculation string

const (
// ReleaseCalculationAuto is the default. azldev auto-bumps the Release tag based on
// synthetic commit history. Static integer releases are incremented; %autorelease
// is handled by rpmautospec.
ReleaseCalculationAuto ReleaseCalculation = "auto"

// ReleaseCalculationManual skips all automatic Release tag manipulation. Use this for
// components that manage their own release numbering (e.g. kernel).
ReleaseCalculationManual ReleaseCalculation = "manual"
)

// Defines a component.
type ComponentConfig struct {
// The component's name; not actually present in serialized files.
Expand All @@ -125,6 +139,11 @@ type ComponentConfig struct {
// Where to get its spec and adjacent files from.
Spec SpecSource `toml:"spec,omitempty" json:"spec,omitempty" jsonschema:"title=Spec,description=Identifies where to find the spec for this component"`

// ReleaseCalculation controls how the Release tag is managed during rendering.
// Defaults to auto (empty). Set to "manual" for components that manage their own
// release numbering (e.g. kernel).
ReleaseCalculation ReleaseCalculation `toml:"release-calculation,omitempty" json:"releaseCalculation,omitempty" validate:"omitempty,oneof=auto manual" jsonschema:"enum=auto,enum=manual,title=Release calculation,description=Controls how the Release tag is managed during rendering. Empty or omitted means auto."`
Comment thread
dmcilvaney marked this conversation as resolved.
Outdated

// Overlays to apply to sources after they've been acquired. May mutate the spec as well as sources.
Overlays []ComponentOverlay `toml:"overlays,omitempty" json:"overlays,omitempty" table:"-" jsonschema:"title=Overlays,description=Overlays to apply to this component's spec and/or sources"`

Expand Down Expand Up @@ -173,6 +192,7 @@ func (c *ComponentConfig) WithAbsolutePaths(referenceDir string) *ComponentConfi
Name: c.Name,
SourceConfigFile: c.SourceConfigFile,
RenderedSpecDir: c.RenderedSpecDir,
ReleaseCalculation: c.ReleaseCalculation,
Spec: deep.MustCopy(c.Spec),
Build: deep.MustCopy(c.Build),
SourceFiles: deep.MustCopy(c.SourceFiles),
Expand Down
23 changes: 23 additions & 0 deletions internal/projectconfig/component_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"
"testing"

"github.com/go-playground/validator/v10"
"github.com/microsoft/azure-linux-dev-tools/internal/projectconfig"
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils"
"github.com/stretchr/testify/assert"
Comment thread
dmcilvaney marked this conversation as resolved.
Expand Down Expand Up @@ -210,3 +211,25 @@ func TestAllowedSourceFilesHashTypes_MatchesJSONSchemaEnum(t *testing.T) {
"'jsonschema' enum value %#q is not in 'AllowedSourceFilesHashTypes'", enumVal)
}
}

func TestReleaseCalculationValidation(t *testing.T) {
validate := validator.New()

// Empty (omitted) is valid — resolved to "auto" by the component resolver.
require.NoError(t, validate.Struct(&projectconfig.ComponentConfig{}))
Comment thread
dmcilvaney marked this conversation as resolved.
Outdated

// Explicit "auto" is valid.
require.NoError(t, validate.Struct(&projectconfig.ComponentConfig{
ReleaseCalculation: projectconfig.ReleaseCalculationAuto,
}))

// Explicit "manual" is valid.
require.NoError(t, validate.Struct(&projectconfig.ComponentConfig{
ReleaseCalculation: projectconfig.ReleaseCalculationManual,
}))

// Invalid value is rejected.
require.Error(t, validate.Struct(&projectconfig.ComponentConfig{
ReleaseCalculation: "manaul",
}))
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,15 @@
"title": "Spec",
"description": "Identifies where to find the spec for this component"
},
"release-calculation": {
"type": "string",
"enum": [
"auto",
"manual"
],
"title": "Release calculation",
"description": "Controls how the Release tag is managed during rendering. Empty or omitted means auto."
},
"overlays": {
"items": {
"$ref": "#/$defs/ComponentOverlay"
Expand Down
Loading
Loading