diff --git a/artifacts.go b/artifacts.go index acd6c113b..93b0c45b7 100644 --- a/artifacts.go +++ b/artifacts.go @@ -51,21 +51,12 @@ type Artifacts struct { // Groups is a list of groups to add to the system when the package is installed. Groups []AddGroupConfig `yaml:"groups,omitempty" json:"groups,omitempty"` - // DisableStrip is used to disable stripping of artifacts. + // DisableStrip disables stripping of root package artifacts. + // It is not supported in subpackages. DisableStrip bool `yaml:"disable_strip,omitempty" json:"disable_strip,omitempty"` - // DisableAutoRequires is used to disable automatic dependency discovery for - // the produced package. - // - // Some tooling, such as `rpmbuild`, will look at all artifacts and - // automatically inject missing dependencies into the package metadata. - // For instance, if you include a `.sh` script, rpmbuild with automatically - // add `bash` as a dependency for the package. - // It also does this for libraries being linked against. - // - // This is useful if you want to have more control over the dependencies - // that are included in the package. - // However, you must be careful to manually include all dependencies that are required. + // DisableAutoRequires disables automatic dependency discovery for package + // artifacts. Required dependencies must be declared explicitly when it is enabled. DisableAutoRequires bool `yaml:"disable_auto_requires,omitempty" json:"disable_auto_requires,omitempty"` } diff --git a/docs/spec.schema.json b/docs/spec.schema.json index 8e79f6660..85ae3d15f 100644 --- a/docs/spec.schema.json +++ b/docs/spec.schema.json @@ -307,13 +307,13 @@ "type": [ "boolean" ], - "description": "DisableAutoRequires is used to disable automatic dependency discovery for\nthe produced package.\n\nSome tooling, such as `rpmbuild`, will look at all artifacts and\nautomatically inject missing dependencies into the package metadata.\nFor instance, if you include a `.sh` script, rpmbuild with automatically\nadd `bash` as a dependency for the package.\nIt also does this for libraries being linked against.\n\nThis is useful if you want to have more control over the dependencies\nthat are included in the package.\nHowever, you must be careful to manually include all dependencies that are required." + "description": "DisableAutoRequires disables automatic dependency discovery for package\nartifacts. Required dependencies must be declared explicitly when it is enabled." }, "disable_strip": { "type": [ "boolean" ], - "description": "DisableStrip is used to disable stripping of artifacts." + "description": "DisableStrip disables stripping of root package artifacts.\nIt is not supported in subpackages." }, "docs": { "additionalProperties": { @@ -2209,6 +2209,74 @@ ], "description": "Spec is the specification for a package build." }, + "SubPackage": { + "required": [ + "description" + ], + "properties": { + "artifacts": { + "$ref": "#/$defs/Artifacts", + "description": "Artifacts specifies which build outputs go into this supplemental package.\nThis is self-contained — no artifacts are inherited from the primary package." + }, + "conflicts": { + "$ref": "#/$defs/PackageDependencyList", + "description": "Conflicts is the list of packages that conflict with this supplemental package." + }, + "dependencies": { + "$ref": "#/$defs/SubPackageDependencies", + "description": "Dependencies specifies runtime dependencies for this supplemental package.\nOnly runtime and recommends dependencies are allowed; build dependencies\nare shared with the primary package." + }, + "description": { + "type": [ + "string" + ], + "description": "Description is the package description. This is required — both RPM and\nDebian require a description/summary for every subpackage.\nBuild argument references are not substituted in this field." + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "Name overrides the default package name.\nBy default, the package name is \"\u003cparent\u003e-\u003ckey\u003e\" where \u003ckey\u003e is the map\nkey under which this SubPackage is defined. Set this to use a fully custom\npackage name instead. Build argument references are not substituted in this field." + }, + "provides": { + "$ref": "#/$defs/PackageDependencyList", + "description": "Provides is the list of things this supplemental package provides." + }, + "replaces": { + "$ref": "#/$defs/PackageDependencyList", + "description": "Replaces is the list of packages that this supplemental package replaces/obsoletes." + } + }, + "additionalProperties": { + "not": {} + }, + "type": [ + "object", + "null" + ], + "description": "SubPackage defines a supplemental package produced from the same build output as the primary package." + }, + "SubPackageDependencies": { + "properties": { + "recommends": { + "$ref": "#/$defs/PackageDependencyList", + "description": "Recommends is the list of packages recommended to install with the supplemental package.\nNote: Not all package managers support this (e.g. rpm)" + }, + "runtime": { + "$ref": "#/$defs/PackageDependencyList", + "description": "Runtime is the list of packages required to install/run the supplemental package." + } + }, + "additionalProperties": { + "not": {} + }, + "type": [ + "object", + "null" + ], + "description": "SubPackageDependencies contains only the dependency fields valid for supplemental packages." + }, "SymlinkTarget": { "required": [ "path", @@ -2386,6 +2454,16 @@ "$ref": "#/$defs/PackageConfig", "description": "PackageConfig is the configuration to use for artifact targets, such as\nrpms, debs, or zip files containing Windows binaries" }, + "packages": { + "additionalProperties": { + "$ref": "#/$defs/SubPackage" + }, + "type": [ + "object", + "null" + ], + "description": "Packages defines supplemental packages produced from the same build output\nas the primary package. Each entry produces an additional package with its\nown artifact selection, runtime dependencies, and metadata.\nThe map key is used as a suffix for the default package name (\"\u003cspecName\u003e-\u003ckey\u003e\")." + }, "provides": { "$ref": "#/$defs/PackageDependencyList", "description": "Provides is the list of packages that this target provides." diff --git a/load.go b/load.go index c5c2ca6ea..f426b6d36 100644 --- a/load.go +++ b/load.go @@ -579,6 +579,9 @@ func (s Spec) Validate() error { if err := t.validate(); err != nil { errs = append(errs, errors.Wrapf(err, "target %s", k)) } + if err := validateSubPackageNames(s.Name, k, t.Packages); err != nil { + errs = append(errs, err) + } } if err := s.Image.validate(); err != nil { diff --git a/subpackage.go b/subpackage.go new file mode 100644 index 000000000..d220376f5 --- /dev/null +++ b/subpackage.go @@ -0,0 +1,215 @@ +package dalec + +import ( + goerrors "errors" + "fmt" + "iter" + + "github.com/moby/buildkit/frontend/dockerfile/shell" + "github.com/pkg/errors" +) + +// SubPackage defines a supplemental package produced from the same build +// output as the primary package. Each supplemental package has its own +// artifact selection, runtime dependencies, and package metadata. +// +// Supplemental packages share the primary package's build steps, sources, +// version, revision, license, vendor, and website. They cannot override +// these fields. +type SubPackage struct { + // Name overrides the default package name. + // By default, the package name is "-" where is the map + // key under which this SubPackage is defined. Set this to use a fully custom + // package name instead. Build argument references are not substituted in this field. + Name string `yaml:"name,omitempty" json:"name,omitempty"` + + // Description is the package description. This is required — both RPM and + // Debian require a description/summary for every subpackage. + // Build argument references are not substituted in this field. + Description string `yaml:"description" json:"description" jsonschema:"required"` + + // Artifacts specifies which build outputs go into this supplemental package. + // This is self-contained — no artifacts are inherited from the primary package. + Artifacts *Artifacts `yaml:"artifacts,omitempty" json:"artifacts,omitempty"` + + // Dependencies specifies runtime dependencies for this supplemental package. + // Only runtime and recommends dependencies are allowed; build dependencies + // are shared with the primary package. + Dependencies *SubPackageDependencies `yaml:"dependencies,omitempty" json:"dependencies,omitempty"` + + // Conflicts is the list of packages that conflict with this supplemental package. + Conflicts PackageDependencyList `yaml:"conflicts,omitempty" json:"conflicts,omitempty"` + + // Provides is the list of things this supplemental package provides. + Provides PackageDependencyList `yaml:"provides,omitempty" json:"provides,omitempty"` + + // Replaces is the list of packages that this supplemental package replaces/obsoletes. + Replaces PackageDependencyList `yaml:"replaces,omitempty" json:"replaces,omitempty"` +} + +// ResolvedName returns the package name that this SubPackage will produce. +// If [SubPackage.Name] is set, it is returned as-is. +// Otherwise, the name is "-". +func (s *SubPackage) ResolvedName(parentName, mapKey string) string { + if s.Name != "" { + return s.Name + } + return parentName + "-" + mapKey +} + +// SubPackageDependencies contains only the dependency fields valid for +// supplemental packages. Build dependencies are shared with the primary +// package and cannot be overridden. +type SubPackageDependencies struct { + // Runtime is the list of packages required to install/run the supplemental package. + Runtime PackageDependencyList `yaml:"runtime,omitempty" json:"runtime,omitempty"` + // Recommends is the list of packages recommended to install with the supplemental package. + // Note: Not all package managers support this (e.g. rpm) + Recommends PackageDependencyList `yaml:"recommends,omitempty" json:"recommends,omitempty"` +} + +func (d *SubPackageDependencies) GetRuntime() PackageDependencyList { + if d == nil { + return nil + } + return d.Runtime +} + +func (d *SubPackageDependencies) GetRecommends() PackageDependencyList { + if d == nil { + return nil + } + return d.Recommends +} + +func (d *SubPackageDependencies) processBuildArgs(lex *shell.Lex, args map[string]string, allowArg func(string) bool) error { + if d == nil { + return nil + } + + var errs []error + for k, v := range d.Runtime { + for i, ver := range v.Version { + updated, err := expandArgs(lex, ver, args, allowArg) + if err != nil { + errs = append(errs, errors.Wrapf(err, "runtime version %s", ver)) + continue + } + v.Version[i] = updated + } + d.Runtime[k] = v + } + + for k, v := range d.Recommends { + for i, ver := range v.Version { + updated, err := expandArgs(lex, ver, args, allowArg) + if err != nil { + errs = append(errs, errors.Wrapf(err, "recommends version %s", ver)) + continue + } + v.Version[i] = updated + } + d.Recommends[k] = v + } + + return goerrors.Join(errs...) +} + +// GetSubPackagesForTarget returns the supplemental packages defined for the +// given target in map-key order. A missing target or a target without +// supplemental packages yields no values. +func GetSubPackagesForTarget(spec *Spec, target string) iter.Seq2[string, SubPackage] { + return SortedMapIter(spec.Targets[target].Packages) +} + +func (s *SubPackage) validate() error { + var errs []error + + if s.Description == "" { + errs = append(errs, fmt.Errorf("description is required")) + } + + if s.Artifacts != nil { + // Subpackages deliberately reuse the root Artifacts public model. DisableStrip + // is currently its sole unsupported field, so accepting one representable + // invalid state avoids maintaining nearly identical public types. + if s.Artifacts.DisableStrip { + errs = append(errs, fmt.Errorf("artifacts: disable_strip is only valid for root package artifacts")) + } + if err := s.Artifacts.validate(); err != nil { + errs = append(errs, errors.Wrap(err, "artifacts")) + } + } + + return goerrors.Join(errs...) +} + +func (s *SubPackage) processBuildArgs(lex *shell.Lex, args map[string]string, allowArg func(string) bool) error { + var errs []error + + if err := s.Dependencies.processBuildArgs(lex, args, allowArg); err != nil { + errs = append(errs, errors.Wrap(err, "dependencies")) + } + + for k, v := range s.Conflicts { + for i, ver := range v.Version { + updated, err := expandArgs(lex, ver, args, allowArg) + if err != nil { + errs = append(errs, errors.Wrapf(err, "conflicts %s version %d", k, i)) + continue + } + s.Conflicts[k].Version[i] = updated + } + } + + for k, v := range s.Provides { + for i, ver := range v.Version { + updated, err := expandArgs(lex, ver, args, allowArg) + if err != nil { + errs = append(errs, errors.Wrapf(err, "provides %s version %d", k, i)) + continue + } + s.Provides[k].Version[i] = updated + } + } + + for k, v := range s.Replaces { + for i, ver := range v.Version { + updated, err := expandArgs(lex, ver, args, allowArg) + if err != nil { + errs = append(errs, errors.Wrapf(err, "replaces %s version %d", k, i)) + continue + } + s.Replaces[k].Version[i] = updated + } + } + + return goerrors.Join(errs...) +} + +// validateSubPackageNames checks that no two supplemental packages in the same +// target resolve to the same name, and that no supplemental package name +// conflicts with the primary package name. +func validateSubPackageNames(specName, targetName string, packages map[string]SubPackage) error { + if len(packages) == 0 { + return nil + } + + var errs []error + seen := make(map[string]string, len(packages)) // resolved name → map key + + for key, pkg := range packages { + resolved := pkg.ResolvedName(specName, key) + + if resolved == specName { + errs = append(errs, fmt.Errorf("target %s: package %q resolves to name %q which conflicts with the primary package name", targetName, key, resolved)) + } + + if prevKey, exists := seen[resolved]; exists { + errs = append(errs, fmt.Errorf("target %s: packages %q and %q both resolve to the same name %q", targetName, prevKey, key, resolved)) + } + seen[resolved] = key + } + + return goerrors.Join(errs...) +} diff --git a/subpackage_test.go b/subpackage_test.go new file mode 100644 index 000000000..d2036acc1 --- /dev/null +++ b/subpackage_test.go @@ -0,0 +1,459 @@ +package dalec + +import ( + "strings" + "testing" + + "github.com/goccy/go-yaml" + "gotest.tools/v3/assert" + "gotest.tools/v3/assert/cmp" +) + +func TestSubPackageResolvedName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pkg SubPackage + parentName string + mapKey string + expected string + }{ + { + name: "default naming", + pkg: SubPackage{Description: "test"}, + parentName: "foo", + mapKey: "debug", + expected: "foo-debug", + }, + { + name: "explicit name override", + pkg: SubPackage{Name: "custom-pkg", Description: "test"}, + parentName: "foo", + mapKey: "debug", + expected: "custom-pkg", + }, + { + name: "empty explicit name uses default", + pkg: SubPackage{Name: "", Description: "test"}, + parentName: "bar", + mapKey: "contrib", + expected: "bar-contrib", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := tt.pkg.ResolvedName(tt.parentName, tt.mapKey) + assert.Check(t, cmp.Equal(got, tt.expected)) + }) + } +} + +func TestRootPackageArtifactControls(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + artifacts Artifacts + }{ + { + name: "A root package with disable_strip enabled is valid", + artifacts: Artifacts{ + DisableStrip: true, + }, + }, + { + name: "A root package with disable_auto_requires enabled is valid", + artifacts: Artifacts{ + DisableAutoRequires: true, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + spec := Spec{ + Name: "tools", + Description: "Tools package", + Website: "https://example.com", + Version: "1.0.0", + Revision: "1", + License: "MIT", + Artifacts: tc.artifacts, + } + + err := spec.Validate() + + assert.NilError(t, err) + }) + } +} + +func TestSubPackageBuildArgumentSubstitution(t *testing.T) { + t.Parallel() + + t.Run("A runtime dependency version containing a build argument is substituted", func(t *testing.T) { + t.Parallel() + + spec := Spec{ + Args: map[string]string{ + "PACKAGE_VERSION": "", + }, + Targets: map[string]Target{ + "linux": { + Packages: map[string]SubPackage{ + "tools": { + Name: "tools", + Description: "Tools package", + Dependencies: &SubPackageDependencies{ + Runtime: PackageDependencyList{ + "runtime": { + Version: []string{"=${PACKAGE_VERSION}"}, + }, + }, + }, + }, + }, + }, + }, + } + + err := spec.SubstituteArgs(map[string]string{"PACKAGE_VERSION": "1.2.3"}) + + assert.NilError(t, err) + pkg := spec.Targets["linux"].Packages["tools"] + assert.DeepEqual(t, pkg.Dependencies.Runtime["runtime"].Version, []string{"=1.2.3"}) + }) +} + +func TestSubPackageFieldsSurviveYAMLMarshalAndUnmarshal(t *testing.T) { + t.Parallel() + + original := SubPackage{ + Name: "custom-name", + Description: "A custom subpackage", + Artifacts: &Artifacts{ + Binaries: map[string]ArtifactConfig{ + "foo-contrib": {SubPath: "foo"}, + }, + }, + Dependencies: &SubPackageDependencies{ + Runtime: PackageDependencyList{ + "openssl-libs": {}, + }, + Recommends: PackageDependencyList{ + "suggested-pkg": {}, + }, + }, + Conflicts: PackageDependencyList{ + "foo": {}, + }, + Provides: PackageDependencyList{ + "foo": {}, + }, + Replaces: PackageDependencyList{ + "old-foo": {}, + }, + } + + data, err := yaml.Marshal(original) + assert.NilError(t, err) + + var roundTripped SubPackage + err = yaml.Unmarshal(data, &roundTripped) + assert.NilError(t, err) + + assert.Check(t, cmp.Equal(roundTripped.Name, original.Name)) + assert.Check(t, cmp.Equal(roundTripped.Description, original.Description)) + + // Check artifacts + assert.Check(t, roundTripped.Artifacts != nil) + assert.Check(t, cmp.Equal(len(roundTripped.Artifacts.Binaries), 1)) + + // Check dependencies + assert.Check(t, roundTripped.Dependencies != nil) + assert.Check(t, cmp.Equal(len(roundTripped.Dependencies.GetRuntime()), 1)) + assert.Check(t, cmp.Equal(len(roundTripped.Dependencies.GetRecommends()), 1)) + + // Check conflicts/provides/replaces + assert.Check(t, cmp.Equal(len(roundTripped.Conflicts), 1)) + assert.Check(t, cmp.Equal(len(roundTripped.Provides), 1)) + assert.Check(t, cmp.Equal(len(roundTripped.Replaces), 1)) +} + +func TestTargetWithSubPackagesYAML(t *testing.T) { + t.Parallel() + + input := ` +targets: + azlinux3: + packages: + debug: + description: "Debug symbols for foo" + artifacts: + binaries: + dbg/foo: + name: foo.dbg + dependencies: + runtime: + foo: {} + contrib: + name: foo-contrib-custom + description: "Foo with contrib extensions" + conflicts: + foo: {} + provides: + foo: {} +` + + var spec Spec + err := yaml.Unmarshal([]byte(input), &spec) + assert.NilError(t, err) + + target, ok := spec.Targets["azlinux3"] + assert.Check(t, ok, "expected azlinux3 target") + assert.Check(t, cmp.Equal(len(target.Packages), 2)) + + debugPkg, ok := target.Packages["debug"] + assert.Check(t, ok, "expected debug package") + assert.Check(t, cmp.Equal(debugPkg.Description, "Debug symbols for foo")) + assert.Check(t, cmp.Equal(debugPkg.ResolvedName("foo", "debug"), "foo-debug")) + + contribPkg, ok := target.Packages["contrib"] + assert.Check(t, ok, "expected contrib package") + assert.Check(t, cmp.Equal(contribPkg.Name, "foo-contrib-custom")) + assert.Check(t, cmp.Equal(contribPkg.ResolvedName("foo", "contrib"), "foo-contrib-custom")) +} + +func TestGetSubPackagesForTarget(t *testing.T) { + t.Parallel() + + spec := &Spec{ + Name: "foo", + Targets: map[string]Target{ + "azlinux3": { + Packages: map[string]SubPackage{ + "debug": { + Description: "debug", + }, + "contrib": { + Name: "custom-contrib", + Description: "contrib", + }, + }, + }, + "jammy": {}, + "other": { + Packages: map[string]SubPackage{ + "other": { + Description: "other", + }, + }, + }, + }, + } + + t.Run("A target with subpackages yields only its packages in map-key order", func(t *testing.T) { + t.Parallel() + + var keys []string + var packages []SubPackage + for key, pkg := range GetSubPackagesForTarget(spec, "azlinux3") { + keys = append(keys, key) + packages = append(packages, pkg) + } + + assert.DeepEqual(t, keys, []string{"contrib", "debug"}) + assert.Equal(t, packages[0].Name, "custom-contrib") + assert.Equal(t, packages[1].Description, "debug") + }) + + t.Run("A target without subpackages yields no values", func(t *testing.T) { + t.Parallel() + + var count int + for range GetSubPackagesForTarget(spec, "jammy") { + count++ + } + + assert.Equal(t, count, 0) + }) + + t.Run("A missing target yields no values", func(t *testing.T) { + t.Parallel() + + var count int + for range GetSubPackagesForTarget(spec, "nonexistent") { + count++ + } + + assert.Equal(t, count, 0) + }) +} + +func TestSubPackageDependenciesNilSafe(t *testing.T) { + t.Parallel() + + t.Run("nil dependency accessors return nil", func(t *testing.T) { + t.Parallel() + + var d *SubPackageDependencies + assert.Check(t, d.GetRuntime() == nil) + assert.Check(t, d.GetRecommends() == nil) + }) + + t.Run("a subpackage with no dependencies substitutes args without error", func(t *testing.T) { + t.Parallel() + + spec := Spec{ + Targets: map[string]Target{ + "linux": { + Packages: map[string]SubPackage{ + "tools": {Description: "Tools package"}, + }, + }, + }, + } + + assert.NilError(t, spec.SubstituteArgs(nil)) + }) +} + +func TestSpecValidateWithSubPackages(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + packages map[string]SubPackage + wantError string + }{ + { + name: "Supplemental packages with artifacts, dependencies, and custom names are accepted", + packages: map[string]SubPackage{ + "debug": { + Description: "Debug symbols", + Artifacts: &Artifacts{ + Binaries: map[string]ArtifactConfig{ + "dbg/foo": {SubPath: "foo.dbg"}, + }, + }, + }, + "contrib": { + Name: "foo-contrib-v2", + Description: "Contributed features", + Dependencies: &SubPackageDependencies{ + Runtime: PackageDependencyList{ + "openssl-libs": {}, + }, + }, + Conflicts: PackageDependencyList{ + "foo": {}, + }, + Provides: PackageDependencyList{ + "foo-contrib": {}, + }, + }, + }, + }, + { + name: "A supplemental package without a description is rejected with quoted key context", + packages: map[string]SubPackage{ + "": {}, + }, + wantError: `package "": description is required`, + }, + { + name: "A supplemental package named after the root package is rejected", + packages: map[string]SubPackage{ + "bad": {Name: "foo", Description: "Conflicts with root"}, + }, + wantError: "conflicts with the primary package name", + }, + { + name: "Supplemental packages with the same custom name are rejected", + packages: map[string]SubPackage{ + "a": {Name: "same-name", Description: "Package A"}, + "b": {Name: "same-name", Description: "Package B"}, + }, + wantError: "both resolve to the same name", + }, + { + name: "A custom name matching another supplemental package default name is rejected", + packages: map[string]SubPackage{ + "debug": {Description: "Default foo-debug"}, + "other": {Name: "foo-debug", Description: "Also foo-debug"}, + }, + wantError: "both resolve to the same name", + }, + { + name: "A supplemental package with disable_strip enabled is rejected", + packages: map[string]SubPackage{ + "tools": { + Description: "Tools package", + Artifacts: &Artifacts{ + DisableStrip: true, + }, + }, + }, + wantError: "artifacts: disable_strip is only valid for root package artifacts", + }, + { + name: "A supplemental package with disable_auto_requires enabled is accepted", + packages: map[string]SubPackage{ + "tools": { + Description: "Tools package", + Artifacts: &Artifacts{ + DisableAutoRequires: true, + }, + }, + }, + }, + { + name: "A supplemental package with strip control unset is accepted", + packages: map[string]SubPackage{ + "tools": { + Description: "Tools package", + Artifacts: &Artifacts{}, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + spec := validSpecWithSubPackages(tc.packages) + err := spec.Validate() + + if tc.wantError == "" { + assert.NilError(t, err) + return + } + + assert.Check(t, err != nil, "expected an error but got nil") + assert.Check(t, strings.Contains(err.Error(), tc.wantError), + "expected error to contain %q, got: %s", tc.wantError, err) + }) + } +} + +func validSpecWithSubPackages(packages map[string]SubPackage) Spec { + return Spec{ + Name: "foo", + Description: "Test package", + Version: "1.0.0", + Revision: "1", + License: "MIT", + Website: "https://example.com", + Targets: map[string]Target{ + "azlinux3": { + Packages: packages, + }, + }, + } +} diff --git a/target.go b/target.go index 51ae3fd30..f7ca92df4 100644 --- a/target.go +++ b/target.go @@ -51,6 +51,12 @@ type Target struct { // Conflicts is the list of packages that this target conflicts with. Conflicts PackageDependencyList `yaml:"conflicts,omitempty" json:"conflicts,omitempty"` + + // Packages defines supplemental packages produced from the same build output + // as the primary package. Each entry produces an additional package with its + // own artifact selection, runtime dependencies, and metadata. + // The map key is used as a suffix for the default package name ("-"). + Packages map[string]SubPackage `yaml:"packages,omitempty" json:"packages,omitempty"` } func (t *Target) validate() error { @@ -73,6 +79,12 @@ func (t *Target) validate() error { errs = append(errs, errors.Wrap(err, "postinstall")) } + for key, pkg := range t.Packages { + if err := pkg.validate(); err != nil { + errs = append(errs, errors.Wrapf(err, "package %q", key)) + } + } + return goerrors.Join(errs...) } @@ -131,6 +143,13 @@ func (t *Target) processBuildArgs(lex *shell.Lex, args map[string]string, allowA } } + for key, pkg := range t.Packages { + if err := pkg.processBuildArgs(lex, args, allowArg); err != nil { + errs = append(errs, errors.Wrapf(err, "package %q", key)) + } + t.Packages[key] = pkg + } + return goerrors.Join(errs...) }