Skip to content

Commit 1e25de8

Browse files
dmcilvaneyCopilot
andauthored
feat: Require components to opt out of auto release calculation (#100)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 06902c4 commit 1e25de8

File tree

12 files changed

+299
-66
lines changed

12 files changed

+299
-66
lines changed

docs/user/reference/config/components.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ A component definition tells azldev where to find the spec file, how to customiz
99
| Field | TOML Key | Type | Required | Description |
1010
|-------|----------|------|----------|-------------|
1111
| Spec source | `spec` | [SpecSource](#spec-source) | No | Where to find the spec file for this component. Inherited from distro defaults if not specified. |
12+
| Release config | `release` | [ReleaseConfig](#release-configuration) | No | Controls how the Release tag is managed during rendering |
1213
| Overlays | `overlays` | array of [Overlay](overlays.md) | No | Modifications to apply to the spec and/or source files |
1314
| Build config | `build` | [BuildConfig](#build-configuration) | No | Build-time options (macros, conditionals, check config) |
1415
| 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" }
9798

9899
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.
99100

101+
## Release Configuration
102+
103+
The `[components.<name>.release]` section controls how azldev manages the Release tag during rendering.
104+
105+
| Field | TOML Key | Type | Required | Description |
106+
|-------|----------|------|----------|-------------|
107+
| Calculation | `calculation` | string | No | `"auto"` (default) = auto-bump; `"manual"` = skip all automatic Release manipulation |
108+
109+
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:
110+
111+
```toml
112+
[components.kernel.release]
113+
calculation = "manual"
114+
```
115+
100116
## Build Configuration
101117

102118
The `[components.<name>.build]` section controls build-time options for a component.

internal/app/azldev/core/components/resolver.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,10 @@ func (r *Resolver) createComponentFromConfig(componentConfig *projectconfig.Comp
463463
componentConfig.Name, err)
464464
}
465465

466+
if componentConfig.Release.Calculation == "" {
467+
componentConfig.Release.Calculation = projectconfig.ReleaseCalculationAuto
468+
}
469+
466470
return &resolvedComponent{
467471
env: r.env,
468472
config: *componentConfig,

internal/app/azldev/core/components/resolver_test.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,22 @@ func setupTestSpec(t *testing.T, testEnv *testutils.TestEnv, path string) projec
3636
SourceType: projectconfig.SpecSourceTypeLocal,
3737
Path: path,
3838
},
39+
Release: projectconfig.ReleaseConfig{
40+
Calculation: projectconfig.ReleaseCalculationAuto,
41+
},
3942
}
4043
}
4144

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

46-
component := projectconfig.ComponentConfig{Name: "test-component"}
49+
component := projectconfig.ComponentConfig{
50+
Name: "test-component",
51+
Release: projectconfig.ReleaseConfig{
52+
Calculation: projectconfig.ReleaseCalculationAuto,
53+
},
54+
}
4755

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

@@ -242,6 +250,9 @@ func TestFindAllComponents_MergesComponentPresentBySpecAndConfig(t *testing.T) {
242250
SourceType: projectconfig.SpecSourceTypeLocal,
243251
Path: testSpecPath,
244252
},
253+
Release: projectconfig.ReleaseConfig{
254+
Calculation: projectconfig.ReleaseCalculationAuto,
255+
},
245256
}
246257

247258
// Find!

internal/app/azldev/core/sources/release.go

Lines changed: 16 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -88,42 +88,34 @@ func BumpStaticRelease(releaseValue string, commitCount int) (string, error) {
8888
return fmt.Sprintf("%d%s", newRelease, suffix), nil
8989
}
9090

91-
// HasUserReleaseOverlay reports whether the given overlay list contains an overlay
92-
// that explicitly sets or updates the Release tag. This is used to determine whether
93-
// a user has configured the component to handle a non-standard Release value
94-
// (e.g. one using a custom macro like %{pkg_release}).
95-
func HasUserReleaseOverlay(overlays []projectconfig.ComponentOverlay) bool {
96-
for _, overlay := range overlays {
97-
if !strings.EqualFold(overlay.Tag, "Release") {
98-
continue
99-
}
100-
101-
if overlay.Type == projectconfig.ComponentOverlaySetSpecTag ||
102-
overlay.Type == projectconfig.ComponentOverlayUpdateSpecTag {
103-
return true
104-
}
105-
}
106-
107-
return false
108-
}
109-
11091
// tryBumpStaticRelease checks whether the component's spec uses %autorelease.
11192
// If not, it bumps the static Release tag by commitCount and applies the change
11293
// as an overlay to the spec file in-place. This ensures that components with static
11394
// release numbers get deterministic version bumps matching the number of synthetic
11495
// commits applied from the project repository.
11596
//
97+
// When the component's release calculation is "manual", this function is a no-op.
98+
//
11699
// When the spec uses %autorelease, this function is a no-op because rpmautospec
117100
// already resolves the release number from git history.
118101
//
119102
// When the Release tag uses a non-standard value (not %autorelease and not a leading
120-
// integer, e.g. %{pkg_release}), the component must define an explicit overlay that
121-
// sets the Release tag. If no such overlay exists, an error is returned.
103+
// integer, e.g. %{pkg_release}), the component must set release.calculation to
104+
// "manual", and likely define an explicit overlay that sets the Release tag.
105+
// If a non-standard Release is found and release.calculation is not "manual",
106+
// an error is returned.
122107
func (p *sourcePreparerImpl) tryBumpStaticRelease(
123108
component components.Component,
124109
sourcesDirPath string,
125110
commitCount int,
126111
) error {
112+
if component.GetConfig().Release.Calculation == projectconfig.ReleaseCalculationManual {
113+
slog.Debug("Component uses manual release calculation; skipping static release bump",
114+
"component", component.GetName())
115+
116+
return nil
117+
}
118+
127119
specPath, err := p.resolveSpecPath(component, sourcesDirPath)
128120
if err != nil {
129121
return err
@@ -142,21 +134,14 @@ func (p *sourcePreparerImpl) tryBumpStaticRelease(
142134
return nil
143135
}
144136

145-
// Skip static release bump if the user has defined an explicit overlay for the Release tag.
146-
if HasUserReleaseOverlay(component.GetConfig().Overlays) {
147-
slog.Debug("Component has an explicit Release overlay; skipping static release bump",
148-
"component", component.GetName())
149-
150-
return nil
151-
}
152-
153137
newRelease, err := BumpStaticRelease(releaseValue, commitCount)
154138
if err != nil {
155139
// The Release tag does not start with an integer (e.g. %{pkg_release})
156-
// and the user did not provide an explicit overlay to set it.
140+
// and the user did not set release.calculation to "manual".
157141
return fmt.Errorf(
158142
"component %#q has a non-standard Release tag value %#q that cannot be auto-bumped; "+
159-
"add a \"spec-set-tag\" overlay for the Release tag in the component configuration:\n%w",
143+
"set 'release.calculation = \"manual\"' in the component configuration "+
144+
"and add a \"spec-set-tag\" overlay for the Release tag if needed:\n%w",
160145
component.GetName(), releaseValue, err)
161146
}
162147

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package sources
5+
6+
import (
7+
"path/filepath"
8+
"testing"
9+
10+
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components/components_testutils"
11+
"github.com/microsoft/azure-linux-dev-tools/internal/projectconfig"
12+
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms"
13+
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils"
14+
"github.com/spf13/afero"
15+
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/require"
17+
"go.uber.org/mock/gomock"
18+
)
19+
20+
const testSourcesDir = "/sources"
21+
22+
func newTestPreparer(memFS afero.Fs) *sourcePreparerImpl {
23+
return &sourcePreparerImpl{
24+
fs: memFS,
25+
}
26+
}
27+
28+
func writeTestSpec(t *testing.T, memFS afero.Fs, name, release string) {
29+
t.Helper()
30+
31+
specDir := filepath.Join(testSourcesDir, name)
32+
require.NoError(t, fileutils.MkdirAll(memFS, specDir))
33+
34+
specPath := filepath.Join(specDir, name+".spec")
35+
content := []byte("Name: " + name + "\nVersion: 1.0.0\nRelease: " + release + "\nSummary: Test\nLicense: MIT\n")
36+
37+
require.NoError(t, fileutils.WriteFile(memFS, specPath, content, fileperms.PublicFile))
38+
}
39+
40+
func mockComponent(
41+
ctrl *gomock.Controller, name string, config *projectconfig.ComponentConfig,
42+
) *components_testutils.MockComponent {
43+
comp := components_testutils.NewMockComponent(ctrl)
44+
comp.EXPECT().GetName().AnyTimes().Return(name)
45+
comp.EXPECT().GetConfig().AnyTimes().Return(config)
46+
47+
return comp
48+
}
49+
50+
func TestTryBumpStaticRelease_ManualSkips(t *testing.T) {
51+
ctrl := gomock.NewController(t)
52+
memFS := afero.NewMemMapFs()
53+
preparer := newTestPreparer(memFS)
54+
55+
comp := mockComponent(ctrl, "kernel", &projectconfig.ComponentConfig{
56+
Release: projectconfig.ReleaseConfig{
57+
Calculation: projectconfig.ReleaseCalculationManual,
58+
},
59+
})
60+
61+
// No spec file needed — should skip before reading anything.
62+
err := preparer.tryBumpStaticRelease(comp, testSourcesDir, 3)
63+
require.NoError(t, err)
64+
}
65+
66+
func TestTryBumpStaticRelease_AutoreleaseSkips(t *testing.T) {
67+
ctrl := gomock.NewController(t)
68+
memFS := afero.NewMemMapFs()
69+
preparer := newTestPreparer(memFS)
70+
71+
writeTestSpec(t, memFS, "test-pkg", "%autorelease")
72+
73+
comp := mockComponent(ctrl, "test-pkg", &projectconfig.ComponentConfig{
74+
Release: projectconfig.ReleaseConfig{
75+
Calculation: projectconfig.ReleaseCalculationAuto,
76+
},
77+
})
78+
79+
err := preparer.tryBumpStaticRelease(comp, filepath.Join(testSourcesDir, "test-pkg"), 3)
80+
require.NoError(t, err)
81+
}
82+
83+
func TestTryBumpStaticRelease_StaticBumps(t *testing.T) {
84+
ctrl := gomock.NewController(t)
85+
memFS := afero.NewMemMapFs()
86+
preparer := newTestPreparer(memFS)
87+
88+
writeTestSpec(t, memFS, "test-pkg", "1%{?dist}")
89+
90+
comp := mockComponent(ctrl, "test-pkg", &projectconfig.ComponentConfig{
91+
Release: projectconfig.ReleaseConfig{
92+
Calculation: projectconfig.ReleaseCalculationAuto,
93+
},
94+
})
95+
96+
err := preparer.tryBumpStaticRelease(comp, filepath.Join(testSourcesDir, "test-pkg"), 3)
97+
require.NoError(t, err)
98+
99+
// Verify the spec was updated.
100+
specPath := filepath.Join(testSourcesDir, "test-pkg", "test-pkg.spec")
101+
content, err := fileutils.ReadFile(memFS, specPath)
102+
require.NoError(t, err)
103+
assert.Contains(t, string(content), "Release: 4%{?dist}")
104+
}
105+
106+
func TestTryBumpStaticRelease_NonStandardErrorsWithoutManual(t *testing.T) {
107+
ctrl := gomock.NewController(t)
108+
memFS := afero.NewMemMapFs()
109+
preparer := newTestPreparer(memFS)
110+
111+
writeTestSpec(t, memFS, "kernel", "%{pkg_release}")
112+
113+
comp := mockComponent(ctrl, "kernel", &projectconfig.ComponentConfig{
114+
Release: projectconfig.ReleaseConfig{
115+
Calculation: projectconfig.ReleaseCalculationAuto,
116+
},
117+
})
118+
119+
err := preparer.tryBumpStaticRelease(comp, filepath.Join(testSourcesDir, "kernel"), 3)
120+
require.Error(t, err)
121+
assert.Contains(t, err.Error(), "cannot be auto-bumped")
122+
assert.Contains(t, err.Error(), "release.calculation")
123+
}
124+
125+
func TestTryBumpStaticRelease_NonStandardSucceedsWithManual(t *testing.T) {
126+
ctrl := gomock.NewController(t)
127+
memFS := afero.NewMemMapFs()
128+
preparer := newTestPreparer(memFS)
129+
130+
writeTestSpec(t, memFS, "kernel", "%{pkg_release}")
131+
132+
comp := mockComponent(ctrl, "kernel", &projectconfig.ComponentConfig{
133+
Release: projectconfig.ReleaseConfig{
134+
Calculation: projectconfig.ReleaseCalculationManual,
135+
},
136+
})
137+
138+
err := preparer.tryBumpStaticRelease(comp, filepath.Join(testSourcesDir, "kernel"), 3)
139+
require.NoError(t, err)
140+
}

internal/app/azldev/core/sources/release_test.go

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88

99
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/sources"
1010
"github.com/microsoft/azure-linux-dev-tools/internal/global/testctx"
11-
"github.com/microsoft/azure-linux-dev-tools/internal/projectconfig"
1211
"github.com/microsoft/azure-linux-dev-tools/internal/rpm/spec"
1312
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils"
1413
"github.com/stretchr/testify/assert"
@@ -116,36 +115,3 @@ func TestGetReleaseTagValue_FileNotFound(t *testing.T) {
116115
_, err := sources.GetReleaseTagValue(ctx.FS(), "/nonexistent.spec")
117116
require.Error(t, err)
118117
}
119-
120-
func TestHasUserReleaseOverlay(t *testing.T) {
121-
for _, testCase := range []struct {
122-
name string
123-
overlays []projectconfig.ComponentOverlay
124-
expected bool
125-
}{
126-
{"no overlays", nil, false},
127-
{"unrelated tag", []projectconfig.ComponentOverlay{
128-
{Type: projectconfig.ComponentOverlaySetSpecTag, Tag: "Version", Value: "1.0"},
129-
}, false},
130-
{"unsupported overlay type", []projectconfig.ComponentOverlay{
131-
{Type: projectconfig.ComponentOverlayAddSpecTag, Tag: "Release", Value: "1%{?dist}"},
132-
}, false},
133-
{"spec-set-tag", []projectconfig.ComponentOverlay{
134-
{Type: projectconfig.ComponentOverlaySetSpecTag, Tag: "Release", Value: "1%{?dist}"},
135-
}, true},
136-
{"spec-update-tag", []projectconfig.ComponentOverlay{
137-
{Type: projectconfig.ComponentOverlayUpdateSpecTag, Tag: "Release", Value: "2%{?dist}"},
138-
}, true},
139-
{"case insensitive", []projectconfig.ComponentOverlay{
140-
{Type: projectconfig.ComponentOverlaySetSpecTag, Tag: "release", Value: "1%{?dist}"},
141-
}, true},
142-
{"mixed overlays", []projectconfig.ComponentOverlay{
143-
{Type: projectconfig.ComponentOverlaySetSpecTag, Tag: "BuildRequires", Value: "gcc"},
144-
{Type: projectconfig.ComponentOverlaySetSpecTag, Tag: "Release", Value: "5%{?dist}"},
145-
}, true},
146-
} {
147-
t.Run(testCase.name, func(t *testing.T) {
148-
assert.Equal(t, testCase.expected, sources.HasUserReleaseOverlay(testCase.overlays))
149-
})
150-
}
151-
}

internal/projectconfig/component.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,26 @@ func (g ComponentGroupConfig) WithAbsolutePaths(referenceDir string) ComponentGr
108108
return result
109109
}
110110

111+
// ReleaseCalculation controls how the Release tag is managed during rendering.
112+
type ReleaseCalculation string
113+
114+
const (
115+
// ReleaseCalculationAuto is the default. azldev auto-bumps the Release tag based on
116+
// synthetic commit history. Static integer releases are incremented; %autorelease
117+
// is handled by rpmautospec.
118+
ReleaseCalculationAuto ReleaseCalculation = "auto"
119+
120+
// ReleaseCalculationManual skips all automatic Release tag manipulation. Use this for
121+
// components that manage their own release numbering (e.g. kernel).
122+
ReleaseCalculationManual ReleaseCalculation = "manual"
123+
)
124+
125+
// ReleaseConfig holds release-related configuration for a component.
126+
type ReleaseConfig struct {
127+
// Calculation controls how the Release tag is managed during rendering.
128+
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."`
129+
}
130+
111131
// Defines a component.
112132
type ComponentConfig struct {
113133
// The component's name; not actually present in serialized files.
@@ -125,6 +145,9 @@ type ComponentConfig struct {
125145
// Where to get its spec and adjacent files from.
126146
Spec SpecSource `toml:"spec,omitempty" json:"spec,omitempty" jsonschema:"title=Spec,description=Identifies where to find the spec for this component"`
127147

148+
// Release configuration for this component.
149+
Release ReleaseConfig `toml:"release,omitempty" json:"release,omitempty" table:"-" jsonschema:"title=Release configuration,description=Configuration for how the Release tag is managed during rendering."`
150+
128151
// Overlays to apply to sources after they've been acquired. May mutate the spec as well as sources.
129152
Overlays []ComponentOverlay `toml:"overlays,omitempty" json:"overlays,omitempty" table:"-" jsonschema:"title=Overlays,description=Overlays to apply to this component's spec and/or sources"`
130153

@@ -173,6 +196,7 @@ func (c *ComponentConfig) WithAbsolutePaths(referenceDir string) *ComponentConfi
173196
Name: c.Name,
174197
SourceConfigFile: c.SourceConfigFile,
175198
RenderedSpecDir: c.RenderedSpecDir,
199+
Release: c.Release,
176200
Spec: deep.MustCopy(c.Spec),
177201
Build: deep.MustCopy(c.Build),
178202
SourceFiles: deep.MustCopy(c.SourceFiles),

0 commit comments

Comments
 (0)