diff --git a/docs/user/reference/config/components.md b/docs/user/reference/config/components.md index 3e7a0c4..78b3957 100644 --- a/docs/user/reference/config/components.md +++ b/docs/user/reference/config/components.md @@ -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 config | `release` | [ReleaseConfig](#release-configuration) | No | Controls how the Release tag is managed during rendering | | 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 | @@ -97,6 +98,21 @@ spec = { type = "local", path = "azurelinux-release.spec" } The `path` is relative to the config file that defines the component. Local spec files and any associated source files should be placed alongside the component's `.comp.toml` file. +## Release Configuration + +The `[components..release]` section controls how azldev manages the Release tag during rendering. + +| Field | TOML Key | Type | Required | Description | +|-------|----------|------|----------|-------------| +| Calculation | `calculation` | string | No | `"auto"` (default) = auto-bump; `"manual"` = skip all automatic Release manipulation | + +Most components use auto mode (the default) and need no release configuration. Set `calculation = "manual"` for components that manage their own release numbering, such as kernel: + +```toml +[components.kernel.release] +calculation = "manual" +``` + ## Build Configuration The `[components..build]` section controls build-time options for a component. diff --git a/internal/app/azldev/core/components/resolver.go b/internal/app/azldev/core/components/resolver.go index 4d66d59..da7cfe5 100644 --- a/internal/app/azldev/core/components/resolver.go +++ b/internal/app/azldev/core/components/resolver.go @@ -463,6 +463,10 @@ func (r *Resolver) createComponentFromConfig(componentConfig *projectconfig.Comp componentConfig.Name, err) } + if componentConfig.Release.Calculation == "" { + componentConfig.Release.Calculation = projectconfig.ReleaseCalculationAuto + } + return &resolvedComponent{ env: r.env, config: *componentConfig, diff --git a/internal/app/azldev/core/components/resolver_test.go b/internal/app/azldev/core/components/resolver_test.go index 83b8114..29c8f3e 100644 --- a/internal/app/azldev/core/components/resolver_test.go +++ b/internal/app/azldev/core/components/resolver_test.go @@ -36,6 +36,9 @@ func setupTestSpec(t *testing.T, testEnv *testutils.TestEnv, path string) projec SourceType: projectconfig.SpecSourceTypeLocal, Path: path, }, + Release: projectconfig.ReleaseConfig{ + Calculation: projectconfig.ReleaseCalculationAuto, + }, } } @@ -43,7 +46,12 @@ func setupTestSpec(t *testing.T, testEnv *testutils.TestEnv, path string) projec func addTestComponentToConfig(t *testing.T, env *testutils.TestEnv) projectconfig.ComponentConfig { t.Helper() - component := projectconfig.ComponentConfig{Name: "test-component"} + component := projectconfig.ComponentConfig{ + Name: "test-component", + Release: projectconfig.ReleaseConfig{ + Calculation: projectconfig.ReleaseCalculationAuto, + }, + } env.Config.Components[component.Name] = component @@ -242,6 +250,9 @@ func TestFindAllComponents_MergesComponentPresentBySpecAndConfig(t *testing.T) { SourceType: projectconfig.SpecSourceTypeLocal, Path: testSpecPath, }, + Release: projectconfig.ReleaseConfig{ + Calculation: projectconfig.ReleaseCalculationAuto, + }, } // Find! diff --git a/internal/app/azldev/core/sources/release.go b/internal/app/azldev/core/sources/release.go index 5c6a46c..2c94f43 100644 --- a/internal/app/azldev/core/sources/release.go +++ b/internal/app/azldev/core/sources/release.go @@ -84,42 +84,34 @@ 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 a non-standard Release is found and release.calculation is not "manual", +// an error is returned. func (p *sourcePreparerImpl) tryBumpStaticRelease( component components.Component, sourcesDirPath string, commitCount int, ) error { + if component.GetConfig().Release.Calculation == projectconfig.ReleaseCalculationManual { + slog.Debug("Component uses manual release calculation; skipping static release bump", + "component", component.GetName()) + + return nil + } + specPath, err := p.resolveSpecPath(component, sourcesDirPath) if err != nil { return err @@ -138,21 +130,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) } diff --git a/internal/app/azldev/core/sources/release_internal_test.go b/internal/app/azldev/core/sources/release_internal_test.go new file mode 100644 index 0000000..91fe6fd --- /dev/null +++ b/internal/app/azldev/core/sources/release_internal_test.go @@ -0,0 +1,140 @@ +// 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{ + Release: projectconfig.ReleaseConfig{ + Calculation: 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{ + Release: projectconfig.ReleaseConfig{ + Calculation: 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{ + Release: projectconfig.ReleaseConfig{ + Calculation: 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{ + Release: projectconfig.ReleaseConfig{ + Calculation: 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{ + Release: projectconfig.ReleaseConfig{ + Calculation: projectconfig.ReleaseCalculationManual, + }, + }) + + err := preparer.tryBumpStaticRelease(comp, filepath.Join(testSourcesDir, "kernel"), 3) + require.NoError(t, err) +} diff --git a/internal/app/azldev/core/sources/release_test.go b/internal/app/azldev/core/sources/release_test.go index 2351689..c9cd81b 100644 --- a/internal/app/azldev/core/sources/release_test.go +++ b/internal/app/azldev/core/sources/release_test.go @@ -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" @@ -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)) - }) - } -} diff --git a/internal/projectconfig/component.go b/internal/projectconfig/component.go index 6803b69..a5404cf 100644 --- a/internal/projectconfig/component.go +++ b/internal/projectconfig/component.go @@ -108,6 +108,26 @@ 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" +) + +// ReleaseConfig holds release-related configuration for a component. +type ReleaseConfig struct { + // Calculation controls how the Release tag is managed during rendering. + Calculation ReleaseCalculation `toml:"calculation,omitempty" json:"calculation,omitempty" validate:"omitempty,oneof=auto manual" jsonschema:"enum=auto,enum=manual,default=auto,title=Release calculation,description=Controls how the Release tag is managed during rendering. Empty or omitted means auto."` +} + // Defines a component. type ComponentConfig struct { // The component's name; not actually present in serialized files. @@ -125,6 +145,9 @@ 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"` + // Release configuration for this component. + Release ReleaseConfig `toml:"release,omitempty" json:"release,omitempty" table:"-" jsonschema:"title=Release configuration,description=Configuration for how the Release tag is managed during rendering."` + // 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"` @@ -173,6 +196,7 @@ func (c *ComponentConfig) WithAbsolutePaths(referenceDir string) *ComponentConfi Name: c.Name, SourceConfigFile: c.SourceConfigFile, RenderedSpecDir: c.RenderedSpecDir, + Release: c.Release, Spec: deep.MustCopy(c.Spec), Build: deep.MustCopy(c.Build), SourceFiles: deep.MustCopy(c.SourceFiles), diff --git a/internal/projectconfig/component_test.go b/internal/projectconfig/component_test.go index 125a55e..f9396e6 100644 --- a/internal/projectconfig/component_test.go +++ b/internal/projectconfig/component_test.go @@ -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" @@ -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.ReleaseConfig{})) + + // Explicit "auto" is valid. + require.NoError(t, validate.Struct(&projectconfig.ReleaseConfig{ + Calculation: projectconfig.ReleaseCalculationAuto, + })) + + // Explicit "manual" is valid. + require.NoError(t, validate.Struct(&projectconfig.ReleaseConfig{ + Calculation: projectconfig.ReleaseCalculationManual, + })) + + // Invalid value is rejected. + require.Error(t, validate.Struct(&projectconfig.ReleaseConfig{ + Calculation: "manaul", + })) +} diff --git a/internal/projectconfig/fingerprint_test.go b/internal/projectconfig/fingerprint_test.go index b3bcb86..2cdd8a9 100644 --- a/internal/projectconfig/fingerprint_test.go +++ b/internal/projectconfig/fingerprint_test.go @@ -32,6 +32,7 @@ func TestAllFingerprintedFieldsHaveDecision(t *testing.T) { reflect.TypeFor[projectconfig.SpecSource](), reflect.TypeFor[projectconfig.DistroReference](), reflect.TypeFor[projectconfig.SourceFileReference](), + reflect.TypeFor[projectconfig.ReleaseConfig](), } // Maps "StructName.FieldName" for every field that should carry a diff --git a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap index b0406be..85d7dd2 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap @@ -106,6 +106,11 @@ "title": "Spec", "description": "Identifies where to find the spec for this component" }, + "release": { + "$ref": "#/$defs/ReleaseConfig", + "title": "Release configuration", + "description": "Configuration for how the Release tag is managed during rendering." + }, "overlays": { "items": { "$ref": "#/$defs/ComponentOverlay" @@ -651,6 +656,22 @@ "additionalProperties": false, "type": "object" }, + "ReleaseConfig": { + "properties": { + "calculation": { + "type": "string", + "enum": [ + "auto", + "manual" + ], + "title": "Release calculation", + "description": "Controls how the Release tag is managed during rendering. Empty or omitted means auto.", + "default": "auto" + } + }, + "additionalProperties": false, + "type": "object" + }, "SourceFileReference": { "properties": { "filename": { diff --git a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap index b0406be..85d7dd2 100755 --- a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap @@ -106,6 +106,11 @@ "title": "Spec", "description": "Identifies where to find the spec for this component" }, + "release": { + "$ref": "#/$defs/ReleaseConfig", + "title": "Release configuration", + "description": "Configuration for how the Release tag is managed during rendering." + }, "overlays": { "items": { "$ref": "#/$defs/ComponentOverlay" @@ -651,6 +656,22 @@ "additionalProperties": false, "type": "object" }, + "ReleaseConfig": { + "properties": { + "calculation": { + "type": "string", + "enum": [ + "auto", + "manual" + ], + "title": "Release calculation", + "description": "Controls how the Release tag is managed during rendering. Empty or omitted means auto.", + "default": "auto" + } + }, + "additionalProperties": false, + "type": "object" + }, "SourceFileReference": { "properties": { "filename": { diff --git a/schemas/azldev.schema.json b/schemas/azldev.schema.json index b0406be..85d7dd2 100644 --- a/schemas/azldev.schema.json +++ b/schemas/azldev.schema.json @@ -106,6 +106,11 @@ "title": "Spec", "description": "Identifies where to find the spec for this component" }, + "release": { + "$ref": "#/$defs/ReleaseConfig", + "title": "Release configuration", + "description": "Configuration for how the Release tag is managed during rendering." + }, "overlays": { "items": { "$ref": "#/$defs/ComponentOverlay" @@ -651,6 +656,22 @@ "additionalProperties": false, "type": "object" }, + "ReleaseConfig": { + "properties": { + "calculation": { + "type": "string", + "enum": [ + "auto", + "manual" + ], + "title": "Release calculation", + "description": "Controls how the Release tag is managed during rendering. Empty or omitted means auto.", + "default": "auto" + } + }, + "additionalProperties": false, + "type": "object" + }, "SourceFileReference": { "properties": { "filename": {