Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
38 changes: 35 additions & 3 deletions docs/user/reference/config/overlays.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Overlays are defined within a component's configuration in your TOML config file

### Spec Overlays

These overlays modify `.spec` files using the structured spec parser, allowing precise targeting of tags and sections.
These overlays modify `.spec` files with lexical tag operations and a structured parser for section-aware operations, allowing precise targeting of tags and sections.

| Type | Description | Required Fields |
|------|-------------|-----------------|
Expand All @@ -20,13 +20,20 @@ These overlays modify `.spec` files using the structured spec parser, allowing p
| `spec-update-tag` | Updates an existing tag; **fails if the tag doesn't exist** | `tag`, `value` |
| `spec-remove-tag` | Removes a tag from the spec; **fails if the tag doesn't exist** | `tag` |
| `spec-prepend-lines` | Prepends lines to the start of a section, or to the top of the file if `section` is omitted; **fails if a named section doesn't exist** | `lines` |
| `spec-prepend-all-lines` | Prepends lines to every matching named section; useful for repeated sections in conditional branches; **fails if no matching section exists** | `section`, `lines` |
| `spec-append-lines` | Appends lines to the end of a section, or to the bottom of the file if `section` is omitted; **fails if a named section doesn't exist** | `lines` |
| `spec-search-replace` | Regex-based search and replace on spec content; targets a single section if `section` is given, otherwise the entire spec | `regex` |
| `spec-remove-section` | Removes an entire section from the spec; **fails if section doesn't exist** | `section` |
| `spec-remove-subpackage` | Removes every section associated with a sub-package (e.g. its `%package`, `%description`, `%files`, `%post`, `%postun`, ...); **fails if no such sections exist** | `package` |
| `patch-add` | Adds a patch file and registers it in the spec (PatchN tag or %patchlist) | `source` |
| `patch-remove` | Removes patch files and their spec references matching a glob pattern | `file` |

> **Conditional section wrappers:** `spec-remove-section` and
> `spec-remove-subpackage` preserve simple wrappers, but reject layouts that
> interleave removed sections with loose content whose linear RPM ownership
> cannot be preserved. Use a whole-spec `spec-search-replace` overlay to make
> an ambiguous layout unambiguous first, then remove the section.

### File Overlays

These overlays modify non-spec source files directly. They cannot be used on `.spec` files. These
Expand Down Expand Up @@ -89,11 +96,11 @@ file = "vendor/**" # files inside the archive
| Description | `description` | Human-readable explanation documenting the need for the change; helps identify overlays in error messages | All (optional) |
| Tag | `tag` | The spec tag name (e.g., `BuildRequires`, `Requires`, `Version`) | `spec-add-tag`, `spec-insert-tag`, `spec-set-tag`, `spec-update-tag`, `spec-remove-tag` |
| Value | `value` | The tag value to set, or value to match for removal | `spec-add-tag`, `spec-insert-tag`, `spec-set-tag`, `spec-update-tag`, `spec-remove-tag` (optional for matching) |
| Section | `section` | The spec section to target (e.g., `%build`, `%install`, `%files`, `%description`). Optional for `spec-prepend-lines`, `spec-append-lines`, and `spec-search-replace` — omit to target the entire spec file. Required for `spec-remove-section`. | `spec-prepend-lines` (optional), `spec-append-lines` (optional), `spec-search-replace` (optional), `spec-remove-section` |
| Section | `section` | The spec section to target (e.g., `%build`, `%install`, `%files`, `%description`). Optional for `spec-prepend-lines`, `spec-append-lines`, and `spec-search-replace` — omit to target the entire spec file. Required for `spec-prepend-all-lines` and `spec-remove-section`. | `spec-prepend-lines` (optional), `spec-prepend-all-lines`, `spec-append-lines` (optional), `spec-search-replace` (optional), `spec-remove-section` |
| Package | `package` | The sub-package name for multi-package specs; omit to target the main package. Cannot be combined with an omitted `section` (a sub-package is always a sub-qualifier of a section). | All spec overlays (optional, except `spec-remove-subpackage` which **requires** it) |
| Regex | `regex` | Regular expression pattern to match | `spec-search-replace`, `file-search-replace` |
| Replacement | `replacement` | Literal replacement text; capture group references like `$1` are **not** expanded. Omit or leave empty to delete matched text. | `spec-search-replace`, `file-search-replace`, `file-rename` |
| Lines | `lines` | Array of text lines to insert | `spec-prepend-lines`, `spec-append-lines`, `file-prepend-lines` |
| Lines | `lines` | Array of text lines to insert | `spec-prepend-lines`, `spec-prepend-all-lines`, `spec-append-lines`, `file-prepend-lines` |
| File | `file` | The name of the non-spec file to modify or add, or a glob pattern. When combined with the `archive` field, the glob is matched against files inside that source archive. | `file-prepend-lines`, `file-search-replace`, `file-add`, `file-remove`, `file-rename`, `patch-add` (optional), `patch-remove` |
| Archive | `archive` | The source archive to extract, modify, and repack (e.g. `pkg-1.0.tar.gz`). When set, `file` is a glob matched relative to the archive's extraction root. | `file-remove`, `file-search-replace` (optional) |
| Source | `source` | Path to source file for `file-add` and `patch-add`; relative paths are relative to the config file that defines the overlay (the overlay file if loaded via [`overlay-files`](#per-file-overlay-format), otherwise the component config) | `file-add`, `patch-add` |
Expand Down Expand Up @@ -625,6 +632,31 @@ the overlay always removes every section associated with the sub-package.
> an error is returned; use a `spec-search-replace` overlay to adjust the conditionals
> before removing the sub-package.

## Known Limitations

### Section-scoped operations and straddling conditionals

Section-scoped tag and search/replace overlays use RPM's linear section ownership, including when a section header is inside a `%if` wrapper and its content continues past `%endif`:

```spec
%if 0%{!?scl:1}
%package headless
Requires: binutils
%endif
# ← content below is still part of %package headless in RPM's view.
Recommends: default-yama-scope
```

In this pattern, `spec-remove-tag` with `package = "headless"` can remove
`Recommends`. Section removal remains more restrictive: it rejects wrappers
that interleave removed sections with loose content whose ownership cannot be
preserved. Use whole-spec `spec-search-replace` to make those layouts
unambiguous before removing a section.

### Macro-generated sections

Specs that use macros like `%ghc_lib_subpackage`, `%pyproject_extras_subpkg`, or `%fontpkg` generate sections at build time that are invisible to the static parser. Section-scoped overlays cannot target these generated sections. Use `spec-search-replace` for modifications that need to reach macro-generated content.

## Validation

Overlay configurations are validated when the config file is loaded. Validation checks:
Expand Down
8 changes: 8 additions & 0 deletions internal/app/azldev/agentskill/content/overlays.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ the config loads, so a missing field fails fast rather than at apply time.
| `spec-update-tag` | change an existing tag; fails if it is missing | `tag`, `value` |
| `spec-remove-tag` | delete tag instances; without `value`, deletes every instance | `tag` |
| `spec-prepend-lines` | insert lines at the top of a section (or the whole file) | `lines` |
| `spec-prepend-all-lines` | insert lines at the top of every matching named section | `section`, `lines` |
| `spec-append-lines` | insert lines at the end of a section (or the whole file) | `lines` |
| `spec-search-replace` | regex replace within a section (or the whole spec) | `regex` |
| `spec-remove-section` | delete a whole section | `section` |
Expand Down Expand Up @@ -114,6 +115,13 @@ the config loads, so a missing field fails fast rather than at apply time.
For a multi-line change use a structured spec overlay (`spec-remove-section`,
`spec-prepend-lines`/`spec-append-lines`, etc.). `file-search-replace` is different: it
matches against the whole file, so multi-line patterns (and `(?s)`) work there.
- **Section deletion rejects ambiguous conditional section wrappers.**
`spec-remove-section` and `spec-remove-subpackage` preserve simple wrapped
sections, but reject wrappers that interleave removed sections with loose
content whose linear RPM ownership cannot be preserved. Section-scoped tag
and search/replace overlays do follow linear ownership across branches and
after `%endif`. Use whole-spec `spec-search-replace` to make an ambiguous
removal layout unambiguous first.
- **`file` is a glob** (`**` supported) for the multi-file file overlays; for `file-add`
and `file-rename` it is a single name, and `file-rename`'s `replacement` is a
filename only (not a path).
Expand Down
5 changes: 5 additions & 0 deletions internal/app/azldev/core/sources/overlays.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,11 @@ func ApplySpecOverlay(overlay projectconfig.ComponentOverlay, openedSpec *spec.S
return fmt.Errorf("failed to prepend lines to spec:\n%w", err)
}
}
case projectconfig.ComponentOverlayPrependAllSpecLines:
err := openedSpec.PrependLinesToAllSections(overlay.SectionName, overlay.PackageName, overlay.Lines)
if err != nil {
return fmt.Errorf("failed to prepend lines to all matching sections in spec:\n%w", err)
}
case projectconfig.ComponentOverlayAppendSpecLines:
if overlay.SectionName == "" {
openedSpec.AppendLines(overlay.Lines)
Expand Down
20 changes: 5 additions & 15 deletions internal/app/azldev/core/sources/release.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"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"
Expand Down Expand Up @@ -46,21 +45,12 @@ func GetReleaseTagValue(fs opctx.FS, specPath string) (string, error) {
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
})
// Preserve the historical visitor behavior: when a spec contains multiple
// lexical Release tags (including conditional alternatives), the last one
// is the value considered for release calculation.
releaseValue, err := openedSpec.GetLastTag("", "Release")
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 "", fmt.Errorf("failed to get Release tag from spec %#q:\n%w", specPath, err)
}

return releaseValue, nil
Expand Down
6 changes: 6 additions & 0 deletions internal/app/azldev/core/sources/release_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ func TestGetReleaseTagValue(t *testing.T) {
{"static with dist", makeSpec("1%{?dist}"), "1%{?dist}", false},
{"autorelease", makeSpec("%autorelease"), "%autorelease", false},
{"braced autorelease", makeSpec("%{autorelease}"), "%{autorelease}", false},
{
"duplicate and conditional releases use last lexical value",
"Name: test-package\n%if 0\nRelease: 1\n%else\nRelease: 2\n%endif\n",
"2", false,
},
{"empty release is present", "Name: test-package\nRelease:\n", "", false},
{"no release tag", "Name: test-package\nVersion: 1.0.0\nSummary: Test\n", "", true},
} {
t.Run(testCase.name, func(t *testing.T) {
Expand Down
7 changes: 4 additions & 3 deletions internal/app/azldev/core/sources/sourceprep.go
Original file line number Diff line number Diff line change
Expand Up @@ -1339,16 +1339,17 @@ func generateFileHeaderOverlay() []projectconfig.ComponentOverlay {
}

// synthesizeCheckSkipOverlays generates overlays to disable the %check section if configured.
// When check.skip is true, it prepends an 'exit 0' to the %check section with a comment
// explaining why the section was disabled.
// When check.skip is true, it prepends an 'exit 0' to every %check section in the spec with
// a comment explaining why the section was disabled. Uses [ComponentOverlayPrependAllSpecLines]
// to handle specs that contain multiple %check sections gated by different conditionals.
func synthesizeCheckSkipOverlays(checkConfig projectconfig.CheckConfig) []projectconfig.ComponentOverlay {
if !checkConfig.Skip {
return nil
}

return []projectconfig.ComponentOverlay{
{
Type: projectconfig.ComponentOverlayPrependSpecLines,
Type: projectconfig.ComponentOverlayPrependAllSpecLines,
SectionName: "%check",
Lines: []string{
"# Check section disabled: " + checkConfig.SkipReason,
Expand Down
37 changes: 20 additions & 17 deletions internal/app/azldev/core/sources/upstream_provenance.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package sources
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"path/filepath"
Expand Down Expand Up @@ -256,24 +257,26 @@ func parseSpecVersionRelease(fs opctx.FS, specPath string) (version, release str
return "", "", fmt.Errorf("failed to parse spec %#q:\n%w", specPath, err)
}

// VisitTagsPackage("") iterates tags in the base (unnamed) package, where
// Name/Version/Release live for a well-formed spec.
visitErr := parsed.VisitTagsPackage("", func(tagLine *spec.TagLine, _ *spec.Context) error {
switch strings.ToLower(tagLine.Tag) {
case "version":
if version == "" {
version = strings.TrimSpace(tagLine.Value)
}
case "release":
if release == "" {
release = strings.TrimSpace(tagLine.Value)
}
}
version, err = parsed.GetFirstNonEmptyTag("", "Version")
if err != nil && !errors.Is(err, spec.ErrNoSuchTag) {
return "", "", fmt.Errorf("failed to read Version tag in spec %#q:\n%w", specPath, err)
}

if errors.Is(err, spec.ErrNoSuchTag) {
version = ""
} else {
version = strings.TrimSpace(version)
}

release, err = parsed.GetFirstNonEmptyTag("", "Release")
if err != nil && !errors.Is(err, spec.ErrNoSuchTag) {
return "", "", fmt.Errorf("failed to read Release tag in spec %#q:\n%w", specPath, err)
}

return nil
})
if visitErr != nil {
return "", "", fmt.Errorf("failed to scan spec tags in %#q:\n%w", specPath, visitErr)
if errors.Is(err, spec.ErrNoSuchTag) {
release = ""
} else {
release = strings.TrimSpace(release)
}

return version, release, nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,20 @@ func TestParseSpecVersionRelease(t *testing.T) {
assert.Equal(t, "5%{?dist}", release, "release is captured verbatim, dist is expanded later")
}

func TestParseSpecVersionReleaseUsesFirstNonEmptyTags(t *testing.T) {
memFS := afero.NewMemMapFs()
require.NoError(t, fileutils.MkdirAll(memFS, provenanceWorkDir))
path := filepath.Join(provenanceWorkDir, "conditional.spec")
content := "Name: conditional\nVersion:\nRelease:\n%if 0\nVersion: 1\nRelease: 1\n" +
"%else\nVersion: 2\nRelease: 2\n%endif\n"
require.NoError(t, fileutils.WriteFile(memFS, path, []byte(content), fileperms.PublicFile))

version, release, err := parseSpecVersionRelease(memFS, path)
require.NoError(t, err)
assert.Equal(t, "1", version)
assert.Equal(t, "1", release)
}

func TestParseSpecVersionRelease_MissingFile(t *testing.T) {
_, _, err := parseSpecVersionRelease(afero.NewMemMapFs(), "/does-not-exist.spec")
require.Error(t, err)
Expand Down
21 changes: 18 additions & 3 deletions internal/projectconfig/overlay.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (
//nolint:recvcheck // HashInclude needs a value receiver for hashstructure; all other methods use pointer receivers.
type ComponentOverlay struct {
// The type of overlay to apply.
Type ComponentOverlayType `toml:"type" json:"type" validate:"required" jsonschema:"enum=spec-add-tag,enum=spec-insert-tag,enum=spec-set-tag,enum=spec-update-tag,enum=spec-remove-tag,enum=spec-prepend-lines,enum=spec-append-lines,enum=spec-search-replace,enum=spec-remove-section,enum=spec-remove-subpackage,enum=patch-add,enum=patch-remove,enum=file-prepend-lines,enum=file-search-replace,enum=file-add,enum=file-remove,enum=file-rename,title=Overlay type,description=The type of overlay to apply"`
Type ComponentOverlayType `toml:"type" json:"type" validate:"required" jsonschema:"enum=spec-add-tag,enum=spec-insert-tag,enum=spec-set-tag,enum=spec-update-tag,enum=spec-remove-tag,enum=spec-prepend-lines,enum=spec-prepend-all-lines,enum=spec-append-lines,enum=spec-search-replace,enum=spec-remove-section,enum=spec-remove-subpackage,enum=patch-add,enum=patch-remove,enum=file-prepend-lines,enum=file-search-replace,enum=file-add,enum=file-remove,enum=file-rename,title=Overlay type,description=The type of overlay to apply"`
// Human readable description of overlay; primarily present to document the need for the change.
Description string `toml:"description,omitempty" json:"description,omitempty" jsonschema:"title=Description,description=Human readable description of overlay" fingerprint:"-"`

Expand Down Expand Up @@ -131,6 +131,7 @@ func (c *ComponentOverlay) ModifiesSpec() bool {
c.Type == ComponentOverlayUpdateSpecTag ||
c.Type == ComponentOverlayRemoveSpecTag ||
c.Type == ComponentOverlayPrependSpecLines ||
c.Type == ComponentOverlayPrependAllSpecLines ||
c.Type == ComponentOverlayAppendSpecLines ||
c.Type == ComponentOverlaySearchAndReplaceInSpec ||
c.Type == ComponentOverlayRemoveSection ||
Expand Down Expand Up @@ -260,6 +261,8 @@ const (
// ComponentOverlayPrependSpecLines is an overlay that prepends lines to a section in a spec; fails if the section
// doesn't exist.
ComponentOverlayPrependSpecLines ComponentOverlayType = "spec-prepend-lines"
// ComponentOverlayPrependAllSpecLines prepends lines to every matching section.
ComponentOverlayPrependAllSpecLines ComponentOverlayType = "spec-prepend-all-lines"
// ComponentOverlayAppendSpecLines is an overlay that appends lines to a section in a spec; fails if the section
// doesn't exist.
ComponentOverlayAppendSpecLines ComponentOverlayType = "spec-append-lines"
Expand Down Expand Up @@ -355,8 +358,8 @@ func (c *ComponentOverlay) validateRequiredFields(desc string) error {
case ComponentOverlayAddSpecTag, ComponentOverlayInsertSpecTag,
ComponentOverlaySetSpecTag, ComponentOverlayUpdateSpecTag, ComponentOverlayRemoveSpecTag:
return c.validateSpecTagFields(desc)
case ComponentOverlayPrependSpecLines, ComponentOverlayAppendSpecLines:
return c.validateSpecLineOverlay(desc)
case ComponentOverlayPrependSpecLines, ComponentOverlayPrependAllSpecLines, ComponentOverlayAppendSpecLines:
return c.validateSpecLineOverlayType(desc)
case ComponentOverlaySearchAndReplaceInSpec:
return c.validateSpecSearchReplaceOverlay(desc)
case ComponentOverlayPrependLinesToFile, ComponentOverlaySearchAndReplaceInFile:
Expand All @@ -378,6 +381,18 @@ func (c *ComponentOverlay) validateRequiredFields(desc string) error {
}
}

func (c *ComponentOverlay) validateSpecLineOverlayType(desc string) error {
if err := c.validateSpecLineOverlay(desc); err != nil {
return err
}

if c.Type == ComponentOverlayPrependAllSpecLines && c.SectionName == "" {
return fmt.Errorf("overlay type %#q requires %#q field: %s", c.Type, "section", desc)
}

return nil
}

func (c *ComponentOverlay) validateSpecTagFields(desc string) error {
if c.Tag == "" {
return fmt.Errorf("overlay type %#q requires %#q field: %s", c.Type, "tag", desc)
Expand Down
19 changes: 19 additions & 0 deletions internal/projectconfig/overlay_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,25 @@ func TestComponentOverlay_Validate(t *testing.T) {
errorExpected: true,
errorContains: "lines",
},
// spec-prepend-all-lines tests
{
name: "spec-prepend-all-lines valid",
overlay: projectconfig.ComponentOverlay{
Type: projectconfig.ComponentOverlayPrependAllSpecLines,
SectionName: "%check",
Lines: []string{"exit 0"},
},
errorExpected: false,
},
{
name: "spec-prepend-all-lines missing section",
overlay: projectconfig.ComponentOverlay{
Type: projectconfig.ComponentOverlayPrependAllSpecLines,
Lines: []string{"exit 0"},
},
errorExpected: true,
errorContains: "section",
},
// spec-append-lines tests
{
name: "spec-append-lines valid",
Expand Down
Loading
Loading