diff --git a/code/go/internal/validator/modes.go b/code/go/internal/validator/modes.go new file mode 100644 index 000000000..70ebc4444 --- /dev/null +++ b/code/go/internal/validator/modes.go @@ -0,0 +1,30 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package validator + +// Mode represents the validation context: which semantic rules run and how +// linked (.link) files are handled during package validation. +type Mode string + +const ( + // LegacyMode preserves the original validation behavior: linked files are + // resolved transparently and no rules are mode-gated. + LegacyMode Mode = "legacy" + // SourceMode validates a package as a checked-out source tree: linked files + // are resolved transparently and source-only rules are enforced. + SourceMode Mode = "source" + // BuildMode validates a package as a built artifact: linked files are + // unconditionally blocked and build-only rules are enforced. + BuildMode Mode = "build" +) + +// Valid reports whether m is a recognised validation mode. +func (m Mode) Valid() bool { + switch m { + case LegacyMode, SourceMode, BuildMode: + return true + } + return false +} diff --git a/code/go/internal/validator/modes_test.go b/code/go/internal/validator/modes_test.go new file mode 100644 index 000000000..63f3d90e3 --- /dev/null +++ b/code/go/internal/validator/modes_test.go @@ -0,0 +1,45 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package validator + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValid(t *testing.T) { + tests := map[string]struct { + mode Mode + valid bool + }{ + "valid": { + mode: LegacyMode, + valid: true, + }, + "invalid": { + mode: Mode("invalid"), + valid: false, + }, + "source": { + mode: SourceMode, + valid: true, + }, + "build": { + mode: BuildMode, + valid: true, + }, + "": { + mode: Mode(""), + valid: false, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + assert.Equal(t, test.valid, test.mode.Valid(), "mode %s should be %v", test.mode, test.valid) + }) + } +} diff --git a/code/go/internal/validator/spec.go b/code/go/internal/validator/spec.go index 884efc095..66bce2f99 100644 --- a/code/go/internal/validator/spec.go +++ b/code/go/internal/validator/spec.go @@ -24,7 +24,8 @@ import ( "github.com/elastic/package-spec/v3/code/go/pkg/specerrors" ) -// Spec represents a package specification +// Spec represents a versioned package specification and the validation mode +// used to evaluate packages against it. type Spec struct { // version is the version requested, what is included in the package, possibly without prerelease tags. version semver.Version @@ -32,6 +33,8 @@ type Spec struct { specVersion semver.Version // fs contains the filesystem of the spec. fs fs.FS + // mode is the validation mode (legacy, source, build). + mode Mode // WarningsAsErrors causes validation warnings to be reported as errors when true. WarningsAsErrors bool @@ -44,12 +47,16 @@ type validationRules []validationRule // GASpecCheckVersion represents minimum version to start checking for unreleased version of the spec var GASpecCheckVersion = semver.MustParse("3.0.1") -// NewSpec creates a new Spec for the given version -func NewSpec(version semver.Version) (*Spec, error) { +// NewSpec creates a new Spec for the given version and validation mode. +// Returns an error if version is not a known spec version or if mode is invalid. +func NewSpec(version semver.Version, mode Mode) (*Spec, error) { specVersion, err := spec.CheckVersion(version) if err != nil { return nil, fmt.Errorf("could not load specification for version [%s]: %w", version.String(), err) } + if !mode.Valid() { + return nil, fmt.Errorf("invalid validation mode %q", mode) + } // With more current versions this is reported as a filterable validation error for GA packages. if version.LessThan(GASpecCheckVersion) { @@ -62,12 +69,15 @@ func NewSpec(version semver.Version) (*Spec, error) { version: version, specVersion: *specVersion, fs: spec.FS(), + mode: mode, } return &s, nil } -// ValidatePackage validates the given Package against the Spec +// ValidatePackage validates the given Package against the Spec, running both +// syntactic and semantic rules. The mode embedded in the Spec controls which +// semantic rules are active. func (s Spec) ValidatePackage(pkg packages.Package) specerrors.ValidationErrors { var errs specerrors.ValidationErrors @@ -199,6 +209,7 @@ func (s Spec) rules(pkgType string, rootSpec spectypes.ItemSpec) validationRules since *semver.Version until *semver.Version types []string + modes []Mode }{ {fn: semantic.ValidateVersionIntegrity}, {fn: semantic.ValidateChangelogLinks}, @@ -260,6 +271,10 @@ func (s Spec) rules(pkgType string, rootSpec spectypes.ItemSpec) validationRules continue } + if rule.modes != nil && !slices.Contains(rule.modes, s.mode) { + continue + } + validationRules = append(validationRules, rule.fn) } diff --git a/code/go/internal/validator/spec_test.go b/code/go/internal/validator/spec_test.go index 2408a386f..538a28be8 100644 --- a/code/go/internal/validator/spec_test.go +++ b/code/go/internal/validator/spec_test.go @@ -26,7 +26,7 @@ func TestNewSpec(t *testing.T) { } for version, test := range tests { - spec, err := NewSpec(*semver.MustParse(version)) + spec, err := NewSpec(*semver.MustParse(version), LegacyMode) if test.expectedErrContains == "" { require.NoError(t, err) require.IsType(t, &Spec{}, spec) @@ -44,6 +44,7 @@ func TestNoBetaFeatures_Package_GA(t *testing.T) { version: *semver.MustParse("1.0.0"), specVersion: *semver.MustParse("1.0.0"), fs: fspath.DirFS("testdata/fakespec"), + mode: LegacyMode, } pkg, err := packages.NewPackage("testdata/packages/features_ga") require.NoError(t, err) @@ -58,6 +59,7 @@ func TestBetaFeatures_Package_GA(t *testing.T) { version: *semver.MustParse("1.0.0"), specVersion: *semver.MustParse("1.0.0"), fs: fspath.DirFS("testdata/fakespec"), + mode: LegacyMode, } pkg, err := packages.NewPackage("testdata/packages/features_beta") require.NoError(t, err) @@ -134,6 +136,7 @@ func TestFolderSpecInvalid(t *testing.T) { version: c.version, specVersion: c.version, fs: c.spec, + mode: LegacyMode, } pkg, err := packages.NewPackage(c.pkgPath) require.NoError(t, err) diff --git a/code/go/pkg/validator/limits_test.go b/code/go/pkg/validator/limits_test.go index 1ccf7abf3..56e07e089 100644 --- a/code/go/pkg/validator/limits_test.go +++ b/code/go/pkg/validator/limits_test.go @@ -116,6 +116,7 @@ func TestLimitsValidation(t *testing.T) { for _, c := range cases { t.Run(c.title, func(t *testing.T) { t.Parallel() + err := ValidateFromFS("test-package", c.fsys) if c.valid { assert.NoError(t, err) diff --git a/code/go/pkg/validator/validator.go b/code/go/pkg/validator/validator.go index dd97af89b..f9afd205e 100644 --- a/code/go/pkg/validator/validator.go +++ b/code/go/pkg/validator/validator.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io/fs" + "log" "os" "github.com/elastic/package-spec/v3/code/go/internal/linkedfiles" @@ -17,25 +18,80 @@ import ( "github.com/elastic/package-spec/v3/code/go/internal/validator/common" ) -// ValidateFromPath validates a package located at the given path against the -// appropriate specification and returns any errors. -func ValidateFromPath(packageRootPath string) error { - // We wrap the fs.FS with a linkedfiles.LinksFS to handle linked files. - linksFS := linkedfiles.NewFS(packageRootPath, os.DirFS(packageRootPath)) - return ValidateFromFS(packageRootPath, linksFS) +// Mode is the validation context that controls semantic rules and linked-file handling. +type Mode = validator.Mode + +var ( + // LegacyMode preserves the original validation behavior. + LegacyMode Mode = validator.LegacyMode + // SourceMode validates a checked-out source tree. + SourceMode Mode = validator.SourceMode + // BuildMode validates a built package artifact. + BuildMode Mode = validator.BuildMode +) + +// Validator holds the configuration for a package validation run. +// Create one with NewValidator, then call ValidateFromPath, ValidateFromZip, or ValidateFromFS. +type Validator struct { + mode Mode + warningsAsErrors bool +} + +// Option configures a Validator. +type Option func(*Validator) + +// WithWarningsAsErrors controls whether validation warnings are promoted to errors. +// When enabled is true, warnings are reported as errors regardless of the +// PACKAGE_SPEC_WARNINGS_AS_ERRORS environment variable. When enabled is false, +// warnings remain warnings even if the environment variable is set. +func WithWarningsAsErrors(enabled bool) Option { + return func(v *Validator) { v.warningsAsErrors = enabled } +} + +// New creates a Validator for the given mode and options. +func New(mode Mode, opts ...Option) (*Validator, error) { + if !mode.Valid() { + return nil, fmt.Errorf("invalid validation mode %q", mode) + } + v := &Validator{ + mode: mode, + warningsAsErrors: common.IsDefinedWarningsAsErrors(), + } + for _, opt := range opts { + opt(v) + } + + return v, nil +} + +// ValidateFromPath validates the package at path on disk. +func (v *Validator) ValidateFromPath(path string) error { + fsys := os.DirFS(path) + if v.mode == BuildMode { + fsys = linkedfiles.NewBlockFS(fsys) + } else { + fsys = linkedfiles.NewFS(path, fsys) + } + + return v.validate(path, fsys) } -// ValidateFromZip validates a package on its zip format. -func ValidateFromZip(packagePath string) error { - r, err := zip.OpenReader(packagePath) +// ValidateFromZip validates the package stored in a zip file. +// Zip files are supported in LegacyMode and BuildMode only. +func (v *Validator) ValidateFromZip(zipPath string) error { + if v.mode != LegacyMode && v.mode != BuildMode { + return errors.New("zip files are only supported in LegacyMode or BuildMode") + } + + r, err := zip.OpenReader(zipPath) if err != nil { - return fmt.Errorf("failed to open zip file (%s): %w", packagePath, err) + return fmt.Errorf("failed to open zip file (%s): %w", zipPath, err) } defer r.Close() dirs, err := fs.ReadDir(r, ".") if err != nil { - return fmt.Errorf("failed to read root directory in zip file (%s): %w", packagePath, err) + return fmt.Errorf("failed to read root directory in zip file (%s): %w", zipPath, err) } if len(dirs) != 1 { return fmt.Errorf("a single directory is expected in zip file, %d found", len(dirs)) @@ -46,39 +102,78 @@ func ValidateFromZip(packagePath string) error { return err } - return ValidateFromFS(packagePath, subDir) + fsys := linkedfiles.NewBlockFS(subDir) + return v.validate(zipPath, fsys) } -// ValidateFromFS validates a package against the appropiate specification and returns any errors. -// Package files are obtained through the given filesystem. -func ValidateFromFS(location string, fsys fs.FS) error { - return validateFromFS(location, fsys, common.IsDefinedWarningsAsErrors()) +// ValidateFromFS validates the package accessible through fsys at location. +func (v *Validator) ValidateFromFS(location string, fsys fs.FS) error { + if v.mode == LegacyMode { + // If we are not explicitly using the linkedfiles.FS, we wrap fsys with + // a linkedfiles.BlockFS to block the use of linked files. + if _, ok := fsys.(*linkedfiles.FS); !ok { + fsys = linkedfiles.NewBlockFS(fsys) + } + } else if _, ok := fsys.(*linkedfiles.FS); ok && v.mode == BuildMode { + return errors.New("linked files are not supported in BuildMode") + } else if _, ok := fsys.(*linkedfiles.BlockFS); ok && v.mode == SourceMode { + return errors.New("block linked files are not supported in SourceMode") + } + + return v.validate(location, fsys) } -func validateFromFS(location string, fsys fs.FS, warningsAsErrors bool) error { - // If we are not explicitly using the linkedfiles.FS, we wrap fsys with - // a linkedfiles.BlockFS to block the use of linked files. - if _, ok := fsys.(*linkedfiles.FS); !ok { - fsys = linkedfiles.NewBlockFS(fsys) - } +func (v *Validator) validate(location string, fsys fs.FS) error { pkg, err := packages.NewPackageFromFS(location, fsys) if err != nil { return err } - if pkg.SpecVersion == nil { return errors.New("could not determine specification version for package") } - spec, err := validator.NewSpec(*pkg.SpecVersion) + spec, err := validator.NewSpec(*pkg.SpecVersion, v.mode) if err != nil { return err } - spec.WarningsAsErrors = warningsAsErrors + spec.WarningsAsErrors = v.warningsAsErrors + + if v.mode != LegacyMode { + log.Printf("Warning: validation mode '%s' is in technical preview", v.mode) + } if errs := spec.ValidatePackage(*pkg); len(errs) > 0 { return errs } - return nil } + +// ValidateFromPath is a convenience function that creates a new Validator in LegacyMode and calls ValidateFromPath. +// Deprecated: Use NewValidator and ValidateFromPath instead. +func ValidateFromPath(path string) error { + v, err := New(LegacyMode) + if err != nil { + return err + } + return v.ValidateFromPath(path) +} + +// ValidateFromZip is a convenience function that creates a new Validator in LegacyMode and calls ValidateFromZip. +// Deprecated: Use NewValidator and ValidateFromZip instead. +func ValidateFromZip(zipPath string) error { + v, err := New(LegacyMode) + if err != nil { + return err + } + return v.ValidateFromZip(zipPath) +} + +// ValidateFromFS is a convenience function that creates a new Validator in LegacyMode and calls ValidateFromFS. +// Deprecated: Use NewValidator and ValidateFromFS instead. +func ValidateFromFS(location string, fsys fs.FS) error { + v, err := New(LegacyMode) + if err != nil { + return err + } + return v.ValidateFromFS(location, fsys) +} diff --git a/code/go/pkg/validator/validator_test.go b/code/go/pkg/validator/validator_test.go index d8d1af80f..879a1fb88 100644 --- a/code/go/pkg/validator/validator_test.go +++ b/code/go/pkg/validator/validator_test.go @@ -5,8 +5,10 @@ package validator import ( + "archive/zip" "errors" "fmt" + "io/fs" "os" "path" "path/filepath" @@ -935,6 +937,8 @@ func TestValidateMinimumKibanaVersions(t *testing.T) { } func TestValidateWarnings(t *testing.T) { + t.Setenv("PACKAGE_SPEC_WARNINGS_AS_ERRORS", "true") + tests := map[string][]string{ "good": {}, "good_v2": {}, @@ -950,12 +954,12 @@ func TestValidateWarnings(t *testing.T) { "good_readme_structure": {}, } - warningsAsErrros := true for pkgName, expectedWarnContains := range tests { t.Run(pkgName, func(t *testing.T) { t.Parallel() pkgRootPath := path.Join("..", "..", "..", "..", "test", "packages", pkgName) - errs := validateFromFS(pkgRootPath, linkedfiles.NewFS(pkgRootPath, os.DirFS(pkgRootPath)), warningsAsErrros) + + errs := ValidateFromFS(pkgRootPath, linkedfiles.NewFS(pkgRootPath, os.DirFS(pkgRootPath))) if len(expectedWarnContains) == 0 { require.NoError(t, errs) } else { @@ -1264,3 +1268,263 @@ func requireErrorMessage(t *testing.T, pkgName string, invalidItemsPerFolder map } require.Len(t, errs, c) } + +func TestLinksBehaviorAcrossModes(t *testing.T) { + withLinks := path.Join("..", "..", "..", "..", "test", "packages", "with_links") + + t.Run("build_rejects_link_files", func(t *testing.T) { + t.Parallel() + + v, err := New(BuildMode) + require.NoError(t, err) + err = v.ValidateFromPath(withLinks) + require.Error(t, err) + errs, ok := err.(specerrors.ValidationErrors) + require.True(t, ok) + require.ErrorContains(t, errs, linkedfiles.ErrUnsupportedLinkFile.Error()) + }) + + t.Run("source_accepts_link_files", func(t *testing.T) { + t.Parallel() + v, err := New(SourceMode) + require.NoError(t, err) + err = v.ValidateFromPath(withLinks) + require.NoError(t, err) + }) + + t.Run("legacy_accepts_link_files", func(t *testing.T) { + t.Parallel() + v, err := New(LegacyMode) + require.NoError(t, err) + err = v.ValidateFromPath(withLinks) + require.NoError(t, err) + }) + +} + +func TestNewValidator_RejectsInvalidMode(t *testing.T) { + _, err := New(Mode("invalid")) + require.Error(t, err) +} + +func TestValidateFromFS_rejectsIncompatibleFS(t *testing.T) { + withLinks := filepath.Join("..", "..", "..", "..", "test", "packages", "with_links") + inner := os.DirFS(withLinks) + + t.Run("build_with_linked_fs", func(t *testing.T) { + t.Parallel() + v, err := New(BuildMode) + require.NoError(t, err) + err = v.ValidateFromFS(withLinks, linkedfiles.NewFS(withLinks, inner)) + require.Error(t, err) + require.ErrorContains(t, err, "linked files are not supported in BuildMode") + }) + + t.Run("source_with_block_fs", func(t *testing.T) { + t.Parallel() + v, err := New(SourceMode) + require.NoError(t, err) + err = v.ValidateFromFS(withLinks, linkedfiles.NewBlockFS(inner)) + require.Error(t, err) + require.ErrorContains(t, err, "block linked files are not supported in SourceMode") + }) + + t.Run("source_with_linked_fs", func(t *testing.T) { + t.Parallel() + v, err := New(SourceMode) + require.NoError(t, err) + err = v.ValidateFromFS(withLinks, linkedfiles.NewFS(withLinks, inner)) + require.NoError(t, err) + }) + + t.Run("build_with_block_fs", func(t *testing.T) { + t.Parallel() + v, err := New(BuildMode) + require.NoError(t, err) + err = v.ValidateFromFS("test-package", linkedfiles.NewBlockFS(newMockFS().Good())) + if err != nil { + require.NotContains(t, err.Error(), "linked files are not supported in BuildMode") + require.NotContains(t, err.Error(), "block linked files are not supported in SourceMode") + } + }) +} + +func TestValidateFromFS_legacyPlainFSBlocksLinks(t *testing.T) { + withLinks := filepath.Join("..", "..", "..", "..", "test", "packages", "with_links") + + t.Run("legacy_plain_fs_blocks_links", func(t *testing.T) { + t.Parallel() + v, err := New(LegacyMode) + require.NoError(t, err) + err = v.ValidateFromFS("test-package", newMockFS().WithLink()) + require.Error(t, err) + errs, ok := err.(specerrors.ValidationErrors) + require.True(t, ok) + for _, e := range errs { + if errors.Is(e, linkedfiles.ErrUnsupportedLinkFile) { + return + } + } + t.Error("links should not be allowed in package") + }) + + t.Run("legacy_linked_fs_accepts_links", func(t *testing.T) { + t.Parallel() + v, err := New(LegacyMode) + require.NoError(t, err) + err = v.ValidateFromFS(withLinks, linkedfiles.NewFS(withLinks, os.DirFS(withLinks))) + require.NoError(t, err) + }) +} + +func writePackageZip(t *testing.T, pkgDir, rootName string) string { + t.Helper() + + zipPath := filepath.Join(t.TempDir(), rootName+".zip") + f, err := os.Create(zipPath) + require.NoError(t, err) + + zw := zip.NewWriter(f) + err = filepath.WalkDir(pkgDir, func(walkPath string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(pkgDir, walkPath) + if err != nil { + return err + } + if rel == "." { + return nil + } + + name := filepath.ToSlash(filepath.Join(rootName, rel)) + if d.IsDir() { + _, err = zw.Create(name + "/") + return err + } + + w, err := zw.Create(name) + if err != nil { + return err + } + content, err := os.ReadFile(walkPath) + if err != nil { + return err + } + _, err = w.Write(content) + return err + }) + require.NoError(t, err) + require.NoError(t, zw.Close()) + require.NoError(t, f.Close()) + return zipPath +} + +func writeMalformedPackageZip(t *testing.T, rootNames ...string) string { + t.Helper() + + zipPath := filepath.Join(t.TempDir(), "malformed.zip") + f, err := os.Create(zipPath) + require.NoError(t, err) + + zw := zip.NewWriter(f) + for _, rootName := range rootNames { + _, err = zw.Create(rootName + "/manifest.yml") + require.NoError(t, err) + } + require.NoError(t, zw.Close()) + require.NoError(t, f.Close()) + return zipPath +} + +func TestValidateFromZip_modeRestrictions(t *testing.T) { + goodPkg := filepath.Join("..", "..", "..", "..", "test", "packages", "good") + zipPath := writePackageZip(t, goodPkg, "good") + + t.Run("source_rejected", func(t *testing.T) { + t.Parallel() + v, err := New(SourceMode) + require.NoError(t, err) + err = v.ValidateFromZip(zipPath) + require.Error(t, err) + require.ErrorContains(t, err, "zip files are only supported in LegacyMode or BuildMode") + }) + + t.Run("legacy_allowed", func(t *testing.T) { + t.Parallel() + v, err := New(LegacyMode) + require.NoError(t, err) + err = v.ValidateFromZip(zipPath) + require.NoError(t, err) + }) + + t.Run("build_allowed", func(t *testing.T) { + t.Parallel() + v, err := New(BuildMode) + require.NoError(t, err) + err = v.ValidateFromZip(zipPath) + require.NoError(t, err) + }) +} + +func TestValidateFromZip_validatesPackage(t *testing.T) { + goodPkg := filepath.Join("..", "..", "..", "..", "test", "packages", "good") + + t.Run("valid_zip", func(t *testing.T) { + t.Parallel() + zipPath := writePackageZip(t, goodPkg, "good") + v, err := New(LegacyMode) + require.NoError(t, err) + require.NoError(t, v.ValidateFromZip(zipPath)) + }) + + t.Run("no_root_directory", func(t *testing.T) { + t.Parallel() + zipPath := writeMalformedPackageZip(t) + v, err := New(LegacyMode) + require.NoError(t, err) + err = v.ValidateFromZip(zipPath) + require.Error(t, err) + require.ErrorContains(t, err, "a single directory is expected in zip file, 0 found") + }) + + t.Run("multiple_root_directories", func(t *testing.T) { + t.Parallel() + zipPath := writeMalformedPackageZip(t, "pkg-a", "pkg-b") + v, err := New(LegacyMode) + require.NoError(t, err) + err = v.ValidateFromZip(zipPath) + require.Error(t, err) + require.ErrorContains(t, err, "a single directory is expected in zip file, 2 found") + }) +} + +func TestDeprecatedValidateFromZip(t *testing.T) { + goodPkg := filepath.Join("..", "..", "..", "..", "test", "packages", "good") + zipPath := writePackageZip(t, goodPkg, "good") + + err := ValidateFromZip(zipPath) + require.NoError(t, err) +} + +func TestWithWarningsAsErrors_option(t *testing.T) { + pkgRootPath := path.Join("..", "..", "..", "..", "test", "packages", "visualizations_by_reference") + fsys := linkedfiles.NewFS(pkgRootPath, os.DirFS(pkgRootPath)) + + t.Run("option_overrides_env_false", func(t *testing.T) { + t.Setenv("PACKAGE_SPEC_WARNINGS_AS_ERRORS", "false") + v, err := New(LegacyMode, WithWarningsAsErrors(true)) + require.NoError(t, err) + err = v.ValidateFromFS(pkgRootPath, fsys) + require.Error(t, err) + require.ErrorContains(t, err, "SVR00004") + }) + + t.Run("option_overrides_env_true", func(t *testing.T) { + t.Setenv("PACKAGE_SPEC_WARNINGS_AS_ERRORS", "true") + v, err := New(LegacyMode, WithWarningsAsErrors(false)) + require.NoError(t, err) + err = v.ValidateFromFS(pkgRootPath, fsys) + require.NoError(t, err) + }) +} diff --git a/spec/changelog.yml b/spec/changelog.yml index 0f846785f..bde89c885 100644 --- a/spec/changelog.yml +++ b/spec/changelog.yml @@ -8,6 +8,9 @@ - description: Add support for semantic_text field definition. type: enhancement link: https://github.com/elastic/package-spec/pull/807 + - description: Add support for mode-aware constructors and validation APIs. + type: enhancement + link: https://github.com/elastic/package-spec/pull/1177 - version: 3.6.3 changes: - description: Add optional `release` field to agentless deployment mode to explicitly declare its release stage.