diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index 780f2598564..bc3a494ad64 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -730,6 +730,7 @@ type extensionInstallFlags struct { version string source string force bool + channel string global *internal.GlobalCommandOptions } @@ -742,6 +743,8 @@ func newExtensionInstallFlags(cmd *cobra.Command, global *internal.GlobalCommand cmd.Flags().StringVarP(&flags.version, "version", "v", "", "The version of the extension to install") cmd.Flags(). BoolVarP(&flags.force, "force", "f", false, "Force installation, including downgrades and reinstalls") + cmd.Flags().StringVar(&flags.channel, "channel", "", + "Pre-release channel to include when selecting the latest version (e.g. 'alpha')") return flags } @@ -845,7 +848,7 @@ func (a *extensionInstallAction) Run(ctx context.Context) (*actions.ActionResult // Check azd version compatibility compatibleExtension, compatResult, err := resolveCompatibleExtension( - selectedExtension, extensionId, a.flags.version, azdVersion, + selectedExtension, extensionId, a.flags.version, azdVersion, a.flags.channel, ) if err != nil { a.console.StopSpinner(ctx, stepMessage, input.StepFailed) @@ -1075,6 +1078,7 @@ type extensionUpgradeFlags struct { source string all bool noDependencyUpgrades bool + channel string global *internal.GlobalCommandOptions } @@ -1087,6 +1091,8 @@ func newExtensionUpgradeFlags(cmd *cobra.Command, global *internal.GlobalCommand cmd.Flags().BoolVar(&flags.all, "all", false, "Upgrade all installed extensions") cmd.Flags().BoolVar(&flags.noDependencyUpgrades, "no-dependency-upgrades", false, "Do not upgrade dependencies when upgrading an extension that has dependencies") + cmd.Flags().StringVar(&flags.channel, "channel", "", + "Pre-release channel to include when selecting the latest version (e.g. 'alpha')") return flags } @@ -1416,7 +1422,7 @@ func (a *extensionUpgradeAction) upgradeOneExtension( // Check azd version compatibility compatExt, compatResult, err := resolveCompatibleExtension( - selectedExt, extensionId, a.flags.version, azdVersion, + selectedExt, extensionId, a.flags.version, azdVersion, a.flags.channel, ) if err != nil { return fail(err) @@ -2256,17 +2262,32 @@ func currentAzdSemver() *semver.Version { // resolveCompatibleExtension filters extension versions for azd version compatibility. // Returns the (possibly filtered) extension metadata and the compatibility result for displaying warnings. // Returns an error if no compatible versions are found or the specific requested version is incompatible. +// When channel is empty, alpha pre-release versions are excluded before any other filtering. +// Pass channel="alpha" to include alpha nightly builds. func resolveCompatibleExtension( selectedExtension *extensions.ExtensionMetadata, extensionId string, requestedVersion string, azdVersion *semver.Version, + channel string, ) (*extensions.ExtensionMetadata, *extensions.VersionCompatibilityResult, error) { if azdVersion == nil { return selectedExtension, nil, nil } - if requestedVersion != "" && requestedVersion != "latest" { + // When no specific version is requested and no channel is set, exclude alpha builds. + if requestedVersion == "" || strings.EqualFold(requestedVersion, "latest") { + if !strings.EqualFold(channel, "alpha") { + filtered := extensions.ExcludePreReleaseChannel(selectedExtension.Versions, "alpha") + if len(filtered) != len(selectedExtension.Versions) { + filteredCopy := *selectedExtension + filteredCopy.Versions = filtered + selectedExtension = &filteredCopy + } + } + } + + if requestedVersion != "" && !strings.EqualFold(requestedVersion, "latest") { // Validate compatibility for the specific requested version if err := validateVersionCompatibility( selectedExtension.Versions, requestedVersion, extensionId, azdVersion, diff --git a/cli/azd/cmd/extension_test.go b/cli/azd/cmd/extension_test.go index 393d1afafa1..6b4109d0e12 100644 --- a/cli/azd/cmd/extension_test.go +++ b/cli/azd/cmd/extension_test.go @@ -253,7 +253,7 @@ func TestResolveCompatibleExtension_NilAzdVersion(t *testing.T) { }, } - result, compat, err := resolveCompatibleExtension(metadata, "test-ext", "", nil) + result, compat, err := resolveCompatibleExtension(metadata, "test-ext", "", nil, "") require.NoError(t, err) assert.Equal(t, metadata, result) assert.Nil(t, compat) @@ -272,7 +272,7 @@ func TestResolveCompatibleExtension_SpecificVersion(t *testing.T) { }, } - result, compat, err := resolveCompatibleExtension(metadata, "test-ext", "0.1.0", azdVersion) + result, compat, err := resolveCompatibleExtension(metadata, "test-ext", "0.1.0", azdVersion, "") require.NoError(t, err) assert.Equal(t, metadata, result) assert.Nil(t, compat) @@ -292,7 +292,7 @@ func TestResolveCompatibleExtension_FilterVersions(t *testing.T) { }, } - result, compat, err := resolveCompatibleExtension(metadata, "test-ext", "", azdVersion) + result, compat, err := resolveCompatibleExtension(metadata, "test-ext", "", azdVersion, "") require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, compat) @@ -314,7 +314,7 @@ func TestResolveCompatibleExtension_NoCompatible(t *testing.T) { }, } - _, compat, err := resolveCompatibleExtension(metadata, "test-ext", "", azdVersion) + _, compat, err := resolveCompatibleExtension(metadata, "test-ext", "", azdVersion, "") require.Error(t, err) assert.Contains(t, err.Error(), "no compatible version") require.NotNil(t, compat) @@ -334,7 +334,7 @@ func TestResolveCompatibleExtension_AllCompatible(t *testing.T) { }, } - result, compat, err := resolveCompatibleExtension(metadata, "test-ext", "", azdVersion) + result, compat, err := resolveCompatibleExtension(metadata, "test-ext", "", azdVersion, "") require.NoError(t, err) assert.Equal(t, metadata, result) // same pointer, no filtering needed require.NotNil(t, compat) @@ -354,11 +354,57 @@ func TestResolveCompatibleExtension_LatestVersion(t *testing.T) { } // "latest" should go through the filter path, not the specific version path - result, _, err := resolveCompatibleExtension(metadata, "test-ext", "latest", azdVersion) + result, _, err := resolveCompatibleExtension(metadata, "test-ext", "latest", azdVersion, "") require.NoError(t, err) assert.Equal(t, metadata, result) } +func TestResolveCompatibleExtension_AlphaChannelFiltering(t *testing.T) { + t.Parallel() + + azdVersion, err := semver.NewVersion("1.24.0") + require.NoError(t, err) + + metadata := &extensions.ExtensionMetadata{ + Id: "test-ext", + Versions: []extensions.ExtensionVersion{ + {Version: "0.9.0-preview.1"}, + {Version: "0.9.0-alpha.202501010000"}, + {Version: "0.10.0-alpha.202502010000"}, + }, + } + + // Without --channel alpha: alpha versions are excluded; highest remaining is preview + result, _, err := resolveCompatibleExtension(metadata, "test-ext", "", azdVersion, "") + require.NoError(t, err) + require.Len(t, result.Versions, 1) + assert.Equal(t, "0.9.0-preview.1", result.Versions[0].Version) + + // With --channel alpha: all versions are included + resultAlpha, _, err := resolveCompatibleExtension(metadata, "test-ext", "", azdVersion, "alpha") + require.NoError(t, err) + assert.Len(t, resultAlpha.Versions, 3) +} + +func TestResolveCompatibleExtension_AlphaOnlyFallback(t *testing.T) { + t.Parallel() + + azdVersion, err := semver.NewVersion("1.24.0") + require.NoError(t, err) + + // When all available versions are alpha, ExcludePreReleaseChannel falls back to original list. + metadata := &extensions.ExtensionMetadata{ + Id: "test-ext", + Versions: []extensions.ExtensionVersion{ + {Version: "0.1.0-alpha.202501010000"}, + }, + } + + result, _, err := resolveCompatibleExtension(metadata, "test-ext", "", azdVersion, "") + require.NoError(t, err) + assert.Len(t, result.Versions, 1) +} + func TestCurrentAzdSemver(t *testing.T) { t.Parallel() diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index 83cd66f20b6..4f5b1d1e3a1 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -5647,6 +5647,15 @@ const completionSpec: Fig.Spec = { name: ['install'], description: 'Installs specified extensions.', options: [ + { + name: ['--channel'], + description: 'Pre-release channel to include when selecting the latest version (e.g. \'alpha\')', + args: [ + { + name: 'channel', + }, + ], + }, { name: ['--force', '-f'], description: 'Force installation, including downgrades and reinstalls', @@ -5810,6 +5819,15 @@ const completionSpec: Fig.Spec = { name: ['--all'], description: 'Upgrade all installed extensions', }, + { + name: ['--channel'], + description: 'Pre-release channel to include when selecting the latest version (e.g. \'alpha\')', + args: [ + { + name: 'channel', + }, + ], + }, { name: ['--no-dependency-upgrades'], description: 'Do not upgrade dependencies when upgrading an extension that has dependencies', diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension-install.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-install.snap index 8197c548ec2..4914afd7cf1 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-extension-install.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-install.snap @@ -5,6 +5,7 @@ Usage azd extension install [flags] Flags + --channel string : Pre-release channel to include when selecting the latest version (e.g. 'alpha') -f, --force : Force installation, including downgrades and reinstalls -s, --source string : The extension source to use for installs -v, --version string : The version of the extension to install diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension-upgrade.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-upgrade.snap index fe6a99bacc5..98849dd1d36 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-extension-upgrade.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-upgrade.snap @@ -6,6 +6,7 @@ Usage Flags --all : Upgrade all installed extensions + --channel string : Pre-release channel to include when selecting the latest version (e.g. 'alpha') --no-dependency-upgrades : Do not upgrade dependencies when upgrading an extension that has dependencies -s, --source string : The extension source to use for upgrades -v, --version string : The version of the extension to upgrade to diff --git a/cli/azd/docs/extensions/extension-resolution-and-versioning.md b/cli/azd/docs/extensions/extension-resolution-and-versioning.md index 79120cec210..b20f2f21764 100644 --- a/cli/azd/docs/extensions/extension-resolution-and-versioning.md +++ b/cli/azd/docs/extensions/extension-resolution-and-versioning.md @@ -99,6 +99,20 @@ azd extension install my.extension --version latest azd extension install my.extension ``` +#### CLI `--channel` flag (pre-release opt-in) + +When resolving `latest` (or when `--version` is omitted), **`alpha` pre-release builds are excluded by default** so that `stable`/`preview` users are never moved onto the nightly channel implicitly. Opt in with `--channel`: + +```bash +# Include alpha nightly builds when resolving the latest version +azd extension install azure.ai.agents --source dev --channel alpha + +# Keep alpha installs on the nightly channel during upgrades +azd extension upgrade --all --channel alpha +``` + +`--channel` is available on both `azd extension install` and `azd extension upgrade`. An explicit `--version ` always installs that exact version, including a named pre-release. If the only versions available for an extension are `alpha`, they are used as a fallback so the install does not dead-end. + #### `azure.yaml` `requiredVersions.extensions` The `requiredVersions.extensions` section in `azure.yaml` supports the full semver constraint syntax provided by the [Masterminds semver](https://github.com/Masterminds/semver) library: @@ -264,7 +278,7 @@ Use pre-release suffixes for testing before a stable release: 2.0.0-rc.1 ``` -When `latest` is specified (or the version is omitted), `azd` selects the **highest semantic version**, which can be a pre-release if it sorts higher than the latest stable version. For semver range constraints in `azure.yaml`, pre-release versions are generally excluded unless the constraint itself explicitly includes a pre-release identifier. +When `latest` is specified (or the version is omitted), `azd` selects the **highest semantic version**, which can be a pre-release if it sorts higher than the latest stable version. The one exception is the CLI `azd extension install`/`upgrade` path, which **excludes `alpha` pre-release builds (the nightly channel) by default** unless `--channel alpha` is passed; other pre-release identifiers (`beta`, `rc`, `preview`, …) remain eligible. For semver range constraints in `azure.yaml`, pre-release versions are generally excluded unless the constraint itself explicitly includes a pre-release identifier. ## Troubleshooting @@ -346,6 +360,26 @@ When using experimental extensions, expect: The dev registry is intended for early adopters, extension authors testing pre-release builds, and internal teams validating extensions before official publication. +### Nightly Builds + +A scheduled pipeline (`eng/pipelines/release-ext-nightly.yml`) publishes **nightly pre-release builds** of the first-party `azure.ai.*` extensions to the dev registry once per day. + +- **Versioning** — Each nightly build is stamped with a timestamped pre-release version derived from the extension's `version.txt`, in the form `-alpha.` (for example `0.1.39-preview.alpha.202606150300`). The timestamp is constant for all extensions in a given run, and newer builds always sort higher than older ones under semver. +- **Change detection** — Only extensions whose source (`cli/azd/extensions/`) changed since their previous nightly are rebuilt and republished. If nothing changed, no pre-release is created and no registry pull request is opened. +- **Publishing** — Each changed extension is published as an unsigned GitHub pre-release tagged `azd-ext-_`, and a single combined pull request updates `registry.dev.json` for all changed extensions. +- **Unsigned** — As with all dev registry binaries, nightly artifacts are **not signed**. + +Because nightly builds use the `alpha` pre-release channel, they are **excluded from default `latest` resolution**. To pick up the latest nightly, opt in with `--channel alpha`: + +```bash +azd extension install azure.ai.agents --source dev --channel alpha +``` + +Once installed from the `alpha` channel, use `azd extension upgrade --channel alpha` to keep moving onto newer nightly builds. The automatic update check is channel-matched: only `alpha` installs are notified about newer `alpha` builds, so `stable`/`preview` installs are never nudged onto the nightly channel. + +> [!NOTE] +> Nightly pre-release versions accumulate in `registry.dev.json` over time. Pruning old nightly entries is handled separately and is not part of the nightly pipeline. + ### Adding the Dev Registry The dev registry is **not** configured by default. To opt in: diff --git a/cli/azd/pkg/extensions/registry_cache.go b/cli/azd/pkg/extensions/registry_cache.go index 83a741ac360..b9041b1e338 100644 --- a/cli/azd/pkg/extensions/registry_cache.go +++ b/cli/azd/pkg/extensions/registry_cache.go @@ -167,28 +167,41 @@ func (m *RegistryCacheManager) Set( return nil } -// GetExtensionLatestVersion finds an extension in the cache and returns its latest version -func (m *RegistryCacheManager) GetExtensionLatestVersion( +// GetExtensionVersions finds an extension in the cache and returns all of its known versions. +func (m *RegistryCacheManager) GetExtensionVersions( ctx context.Context, sourceName string, extensionId string, -) (string, error) { +) ([]ExtensionVersion, error) { cache, err := m.Get(ctx, sourceName) if err != nil { - return "", err + return nil, err } for _, ext := range cache.Extensions { if strings.EqualFold(ext.Id, extensionId) { if len(ext.Versions) == 0 { - return "", fmt.Errorf("extension %s has no versions", extensionId) + return nil, fmt.Errorf("extension %s has no versions", extensionId) } - latest := LatestVersion(ext.Versions) - return latest.Version, nil + return ext.Versions, nil } } - return "", fmt.Errorf("extension %s not found in cache", extensionId) + return nil, fmt.Errorf("extension %s not found in cache", extensionId) +} + +// GetExtensionLatestVersion finds an extension in the cache and returns its latest version +func (m *RegistryCacheManager) GetExtensionLatestVersion( + ctx context.Context, + sourceName string, + extensionId string, +) (string, error) { + versions, err := m.GetExtensionVersions(ctx, sourceName, extensionId) + if err != nil { + return "", err + } + + return LatestVersion(versions).Version, nil } // IsExpiredOrMissing checks if cache for a source needs refresh diff --git a/cli/azd/pkg/extensions/update_checker.go b/cli/azd/pkg/extensions/update_checker.go index a1da3316c68..5ce5ad548db 100644 --- a/cli/azd/pkg/extensions/update_checker.go +++ b/cli/azd/pkg/extensions/update_checker.go @@ -63,13 +63,22 @@ func (c *UpdateChecker) CheckForUpdate( } // Get latest version from cache - latestVersion, err := c.cacheManager.GetExtensionLatestVersion(ctx, extension.Source, extension.Id) + versions, err := c.cacheManager.GetExtensionVersions(ctx, extension.Source, extension.Id) if err != nil { // Cache miss or extension not found - not an error, just no update info - log.Printf("could not get latest version for %s: %v", extension.Id, err) + log.Printf("could not get versions for %s: %v", extension.Id, err) return result, nil } + // Channel matching: only surface alpha pre-release builds when the installed + // version is itself an alpha build. Stable/preview installs ignore alpha so the + // auto-update prompt stays consistent with what 'azd extension upgrade' selects + // by default (without --channel alpha). + if !IsPreReleaseChannel(extension.Version, "alpha") { + versions = ExcludePreReleaseChannel(versions, "alpha") + } + + latestVersion := LatestVersion(versions).Version result.LatestVersion = latestVersion // Compare versions using semver diff --git a/cli/azd/pkg/extensions/update_checker_test.go b/cli/azd/pkg/extensions/update_checker_test.go index 3b66f1d8603..aa8713cb1d9 100644 --- a/cli/azd/pkg/extensions/update_checker_test.go +++ b/cli/azd/pkg/extensions/update_checker_test.go @@ -212,6 +212,55 @@ func Test_UpdateChecker_PrereleaseVersions(t *testing.T) { require.True(t, result.HasUpdate) } +func Test_UpdateChecker_AlphaChannelMatching(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("AZD_CONFIG_DIR", tempDir) + + cacheManager, err := NewRegistryCacheManager() + require.NoError(t, err) + + ctx := t.Context() + sourceName := "test-source" + + extensions := []*ExtensionMetadata{ + { + Id: "test.extension", + DisplayName: "Test Extension", + Versions: []ExtensionVersion{ + {Version: "0.9.0-preview.1"}, + {Version: "0.10.0-alpha.202502010000"}, + }, + }, + } + err = cacheManager.Set(ctx, sourceName, extensions) + require.NoError(t, err) + + updateChecker := NewUpdateChecker(cacheManager) + + // A preview install must NOT be nudged toward an alpha build; the alpha is + // filtered out so the only candidate is the already-installed preview. + previewExt := &Extension{ + Id: "test.extension", + Version: "0.9.0-preview.1", + Source: sourceName, + } + result, err := updateChecker.CheckForUpdate(ctx, previewExt) + require.NoError(t, err) + require.False(t, result.HasUpdate) + require.Equal(t, "0.9.0-preview.1", result.LatestVersion) + + // An alpha install stays on the alpha channel and is offered the newer alpha. + alphaExt := &Extension{ + Id: "test.extension", + Version: "0.9.0-alpha.202501010000", + Source: sourceName, + } + result, err = updateChecker.CheckForUpdate(ctx, alphaExt) + require.NoError(t, err) + require.True(t, result.HasUpdate) + require.Equal(t, "0.10.0-alpha.202502010000", result.LatestVersion) +} + func Test_UpdateChecker_InvalidVersions(t *testing.T) { tempDir := t.TempDir() t.Setenv("AZD_CONFIG_DIR", tempDir) diff --git a/cli/azd/pkg/extensions/version_compatibility.go b/cli/azd/pkg/extensions/version_compatibility.go index 43158c4875a..66b7b1caee7 100644 --- a/cli/azd/pkg/extensions/version_compatibility.go +++ b/cli/azd/pkg/extensions/version_compatibility.go @@ -5,6 +5,7 @@ package extensions import ( "log" + "strings" "github.com/Masterminds/semver/v3" ) @@ -115,3 +116,35 @@ func FilterCompatibleVersions( return result } + +// IsPreReleaseChannel reports whether version's semver pre-release identifier +// starts with channel. For example, IsPreReleaseChannel("0.1.0-alpha.1", "alpha") +// returns true. Returns false when version cannot be parsed as semver or channel is empty. +func IsPreReleaseChannel(version, channel string) bool { + if channel == "" { + return false + } + sv, err := semver.NewVersion(version) + if err != nil { + return false + } + return strings.HasPrefix(sv.Prerelease(), channel) +} + +// ExcludePreReleaseChannel returns a copy of versions with all entries whose +// semver pre-release identifier starts with channel removed. +// For example, ExcludePreReleaseChannel(versions, "alpha") drops all alpha nightly builds. +// Versions that cannot be parsed as semver are kept unchanged. +// Returns the original slice if no versions would remain after filtering. +func ExcludePreReleaseChannel(versions []ExtensionVersion, channel string) []ExtensionVersion { + result := make([]ExtensionVersion, 0, len(versions)) + for _, v := range versions { + if !IsPreReleaseChannel(v.Version, channel) { + result = append(result, v) + } + } + if len(result) == 0 { + return versions + } + return result +} diff --git a/cli/azd/pkg/extensions/version_compatibility_test.go b/cli/azd/pkg/extensions/version_compatibility_test.go index 300fb547015..1098c4dabc1 100644 --- a/cli/azd/pkg/extensions/version_compatibility_test.go +++ b/cli/azd/pkg/extensions/version_compatibility_test.go @@ -402,3 +402,58 @@ func Test_LatestVersion(t *testing.T) { require.Equal(t, "0.1.1", LatestVersion(versions).Version) }) } + +func Test_IsPreReleaseChannel(t *testing.T) { + tests := []struct { + name string + version string + channel string + expect bool + }{ + {"alpha match", "0.1.0-alpha.202501010000", "alpha", true}, + {"preview is not alpha", "0.1.0-preview.1", "alpha", false}, + {"stable is not alpha", "1.0.0", "alpha", false}, + {"beta is not alpha", "1.0.0-beta.1", "alpha", false}, + {"empty channel", "0.1.0-alpha.1", "", false}, + {"unparseable version", "not-a-version", "alpha", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expect, IsPreReleaseChannel(tt.version, tt.channel)) + }) + } +} + +func Test_ExcludePreReleaseChannel(t *testing.T) { + t.Run("drops alpha versions", func(t *testing.T) { + versions := []ExtensionVersion{ + {Version: "0.9.0-preview.1"}, + {Version: "0.10.0-alpha.202502010000"}, + {Version: "1.0.0"}, + } + result := ExcludePreReleaseChannel(versions, "alpha") + require.Len(t, result, 2) + require.Equal(t, "0.9.0-preview.1", result[0].Version) + require.Equal(t, "1.0.0", result[1].Version) + }) + + t.Run("falls back to original when all excluded", func(t *testing.T) { + versions := []ExtensionVersion{ + {Version: "0.1.0-alpha.202501010000"}, + {Version: "0.2.0-alpha.202502010000"}, + } + result := ExcludePreReleaseChannel(versions, "alpha") + require.Len(t, result, 2) + }) + + t.Run("keeps unparseable versions", func(t *testing.T) { + versions := []ExtensionVersion{ + {Version: "nightly"}, + {Version: "0.1.0-alpha.1"}, + } + result := ExcludePreReleaseChannel(versions, "alpha") + require.Len(t, result, 1) + require.Equal(t, "nightly", result[0].Version) + }) +} diff --git a/eng/pipelines/release-ext-nightly.yml b/eng/pipelines/release-ext-nightly.yml new file mode 100644 index 00000000000..c83891a7743 --- /dev/null +++ b/eng/pipelines/release-ext-nightly.yml @@ -0,0 +1,70 @@ +# Nightly (scheduled) release pipeline for azd azure.ai.* extensions. +# +# Once per day this pipeline builds every listed extension that changed since its +# last nightly build, publishes a GitHub pre-release per changed extension with a +# timestamped alpha version (-alpha.), and opens a single +# combined pull request updating the dev extension registry +# (cli/azd/extensions/registry.dev.json, served via +# https://aka.ms/azd/extensions/registry/dev). +# +# Build.BuildNumber is set to yyyyMMddHHmm via the `name` field below so the +# version suffix is identical for every job in the run. + +name: $(Date:yyyyMMddHHmm) + +# Scheduled and manual only. This pipeline has side effects (GitHub pre-releases +# and a dev-registry PR), so it must not run on CI/PR. The publishing steps are +# additionally gated on Build.Reason in the stages template as defense in depth. +trigger: none +pr: none + +schedules: + - cron: "0 8 * * *" + displayName: Daily nightly extension build (08:00 UTC) + branches: + include: + - main + always: true + +parameters: + - name: Extensions + type: object + default: + - Id: azure.ai.agents + SanitizedId: azure-ai-agents + Directory: cli/azd/extensions/azure.ai.agents + - Id: azure.ai.connections + SanitizedId: azure-ai-connections + Directory: cli/azd/extensions/azure.ai.connections + - Id: azure.ai.finetune + SanitizedId: azure-ai-finetune + Directory: cli/azd/extensions/azure.ai.finetune + - Id: azure.ai.inspector + SanitizedId: azure-ai-inspector + Directory: cli/azd/extensions/azure.ai.inspector + - Id: azure.ai.models + SanitizedId: azure-ai-models + Directory: cli/azd/extensions/azure.ai.models + - Id: azure.ai.projects + SanitizedId: azure-ai-projects + Directory: cli/azd/extensions/azure.ai.projects + - Id: azure.ai.routines + SanitizedId: azure-ai-routines + Directory: cli/azd/extensions/azure.ai.routines + - Id: azure.ai.skills + SanitizedId: azure-ai-skills + Directory: cli/azd/extensions/azure.ai.skills + - Id: azure.ai.toolboxes + SanitizedId: azure-ai-toolboxes + Directory: cli/azd/extensions/azure.ai.toolboxes + - Id: azure.ai.training + SanitizedId: azure-ai-training + Directory: cli/azd/extensions/azure.ai.training + +extends: + template: /eng/pipelines/templates/stages/1es-redirect.yml + parameters: + stages: + - template: /eng/pipelines/templates/stages/release-azd-extensions-nightly.yml + parameters: + Extensions: ${{ parameters.Extensions }} diff --git a/eng/pipelines/templates/jobs/build-azd-extension.yml b/eng/pipelines/templates/jobs/build-azd-extension.yml index a12ae98d977..13bfd64558f 100644 --- a/eng/pipelines/templates/jobs/build-azd-extension.yml +++ b/eng/pipelines/templates/jobs/build-azd-extension.yml @@ -21,6 +21,9 @@ parameters: - name: SkipTests type: boolean default: false + - name: VersionSuffix + type: string + default: '' jobs: - job: BuildExtension_${{ parameters.NameSuffix }} @@ -47,6 +50,7 @@ jobs: filePath: eng/scripts/Set-ExtensionVersionVariable.ps1 arguments: >- -ExtensionDirectory ${{ parameters.AzdExtensionDirectory }} + -Suffix '${{ parameters.VersionSuffix }}' - ${{ if not(parameters.SkipTests) }}: - task: PowerShell@2 diff --git a/eng/pipelines/templates/jobs/cross-build-azd-extension.yml b/eng/pipelines/templates/jobs/cross-build-azd-extension.yml index b8a711bfa19..de1d951cbe6 100644 --- a/eng/pipelines/templates/jobs/cross-build-azd-extension.yml +++ b/eng/pipelines/templates/jobs/cross-build-azd-extension.yml @@ -27,6 +27,9 @@ parameters: - name: ValidationScript type: string default: echo "validation script goes here"; exit 1; + - name: VersionSuffix + type: string + default: '' jobs: - job: CrossBuildCLI_${{ parameters.NameSuffix }} @@ -57,6 +60,7 @@ jobs: filePath: eng/scripts/Set-ExtensionVersionVariable.ps1 arguments: >- -ExtensionDirectory ${{ parameters.AzdExtensionDirectory }} + -Suffix '${{ parameters.VersionSuffix }}' - task: PowerShell@2 inputs: diff --git a/eng/pipelines/templates/stages/release-azd-extensions-nightly.yml b/eng/pipelines/templates/stages/release-azd-extensions-nightly.yml new file mode 100644 index 00000000000..687efe12182 --- /dev/null +++ b/eng/pipelines/templates/stages/release-azd-extensions-nightly.yml @@ -0,0 +1,405 @@ +# Nightly (scheduled) release for azd extensions. +# +# Builds each listed extension with a timestamped pre-release version +# (-alpha.), publishes a GitHub pre-release per extension, +# and opens a single combined pull request updating the dev extension registry +# (cli/azd/extensions/registry.dev.json). +# +# Only extensions that changed since their last nightly pre-release are built and +# published. If nothing changed, no pre-releases are created and no PR is opened. +# +# This template is intentionally isolated from the manual release templates +# (release-azd-extension.yml). It reuses only the low-level build jobs +# (build-azd-extension.yml / cross-build-azd-extension.yml), which produce +# uniquely named artifacts, and performs packaging + GitHub pre-release inline so +# that running many extensions in one pipeline run cannot collide on artifact +# names. Signing is intentionally skipped (dev registry binaries are unsigned). + +parameters: + - name: Extensions + type: object + # Each entry: { Id: , SanitizedId: , Directory: } + - name: SkipTests + type: boolean + default: true + - name: VersionSuffix + type: string + # Shared across every job in the run. $(Build.BuildNumber) is set to + # yyyyMMddHHmm by the pipeline name so the suffix is constant for the run. + default: alpha.$(Build.BuildNumber) + +stages: + # -------------------------------------------------------------------------- + # Detect which extensions changed since their last nightly pre-release. + # -------------------------------------------------------------------------- + - stage: Prepare + displayName: Prepare (detect changed extensions) + variables: + - template: /eng/pipelines/templates/variables/globals.yml + - template: /eng/pipelines/templates/variables/image.yml + jobs: + - job: DetectChanges + displayName: Detect changed extensions + pool: + name: $(LINUXPOOL) + image: $(LINUXVMIMAGE) + os: linux + steps: + - checkout: self + fetchDepth: 0 + fetchTags: true + + - pwsh: | + # Native command failures should not throw so we can inspect + # $LASTEXITCODE (git diff --quiet uses exit code 1 to signal diffs). + $PSNativeCommandUseErrorActionPreference = $false + + $extensions = @' + ${{ convertToJson(parameters.Extensions) }} + '@ | ConvertFrom-Json + + $anyChanges = $false + foreach ($ext in $extensions) { + $outName = ($ext.SanitizedId -replace '-', '_') + $tagPrefix = "azd-ext-$($ext.SanitizedId)_" + $shouldBuild = $true + $reason = "no previous nightly release found" + + # Use local git tags (available because fetchTags: true is set above) to + # find the latest nightly tag. This avoids the --limit cap of + # `gh release list`, which would cause false "no previous nightly" results + # for extensions that haven't changed in many months. + # Tag format: azd-ext-_-alpha. + # Lexicographic sort on the timestamp suffix is correct because the format + # is fixed-width (yyyyMMddHHmm). + $lastTag = git tag -l "$tagPrefix*" | + Where-Object { $_ -like "*alpha*" } | + Sort-Object | + Select-Object -Last 1 + + if ($lastTag) { + $lastCommit = git rev-list -n 1 $lastTag 2>$null + if ($LASTEXITCODE -eq 0 -and $lastCommit) { + git diff --quiet $lastCommit HEAD -- $ext.Directory + if ($LASTEXITCODE -eq 0) { + $shouldBuild = $false + $reason = "no changes in $($ext.Directory) since $lastTag" + } else { + $reason = "changes detected in $($ext.Directory) since $lastTag" + } + } else { + $reason = "unable to resolve commit for tag $lastTag; building" + } + } + + $value = $shouldBuild.ToString().ToLower() + Write-Host "$($ext.Id): ShouldBuild=$value ($reason)" + Write-Host "##vso[task.setvariable variable=ShouldBuild_$outName;isOutput=true]$value" + if ($shouldBuild) { $anyChanges = $true } + } + + Write-Host "AnyChanges=$($anyChanges.ToString().ToLower())" + Write-Host "##vso[task.setvariable variable=AnyChanges;isOutput=true]$($anyChanges.ToString().ToLower())" + name: detect + displayName: Detect changed extensions + + # -------------------------------------------------------------------------- + # Per-extension build + GitHub pre-release. Each stage is gated on the + # change-detection result for that extension. + # -------------------------------------------------------------------------- + - ${{ each ext in parameters.Extensions }}: + - stage: Build_${{ replace(ext.SanitizedId, '-', '_') }} + displayName: Build ${{ ext.Id }} + dependsOn: Prepare + condition: >- + and( + succeeded(), + eq(dependencies.Prepare.outputs['DetectChanges.detect.ShouldBuild_${{ replace(ext.SanitizedId, '-', '_') }}'], 'true') + ) + variables: + - template: /eng/pipelines/templates/variables/globals.yml + - template: /eng/pipelines/templates/variables/image.yml + - name: SanitizedExtensionId + value: ${{ ext.SanitizedId }} + jobs: + # amd64 builds (tests skipped for nightly) + - template: /eng/pipelines/templates/jobs/build-azd-extension.yml + parameters: + NameSuffix: Windows + Pool: $(WINDOWSPOOL) + OSVmImage: $(WINDOWSVMIMAGE) + OS: windows + ImageKey: image + UploadArtifact: true + AzdExtensionDirectory: ${{ ext.Directory }} + SkipTests: ${{ parameters.SkipTests }} + VersionSuffix: ${{ parameters.VersionSuffix }} + Variables: + BuildTarget: ${{ ext.SanitizedId }}-windows-amd64.exe + BuildOutputName: ${{ ext.SanitizedId }}-windows-amd64.exe + AZURE_DEV_CI_OS: win + + - template: /eng/pipelines/templates/jobs/build-azd-extension.yml + parameters: + NameSuffix: Linux + Pool: $(LINUXPOOL) + OSVmImage: $(LINUXVMIMAGE) + OS: linux + ImageKey: image + UploadArtifact: true + AzdExtensionDirectory: ${{ ext.Directory }} + SkipTests: ${{ parameters.SkipTests }} + VersionSuffix: ${{ parameters.VersionSuffix }} + Variables: + BuildTarget: ${{ ext.SanitizedId }}-linux-amd64 + BuildOutputName: ${{ ext.SanitizedId }}-linux-amd64 + SetExecutableBit: true + AZURE_DEV_CI_OS: lin + + - template: /eng/pipelines/templates/jobs/build-azd-extension.yml + parameters: + NameSuffix: Mac + Pool: Azure Pipelines + OSVmImage: $(MACVMIMAGE) + OS: macOS + ImageKey: vmImage + UploadArtifact: true + AzdExtensionDirectory: ${{ ext.Directory }} + SkipTests: ${{ parameters.SkipTests }} + VersionSuffix: ${{ parameters.VersionSuffix }} + Variables: + BuildTarget: ${{ ext.SanitizedId }}-darwin-amd64 + BuildOutputName: ${{ ext.SanitizedId }}-darwin-amd64 + MacLocalSign: false + SetExecutableBit: true + AZURE_DEV_CI_OS: mac + + # arm64 cross builds (ARM VM validation disabled for nightly) + - template: /eng/pipelines/templates/jobs/cross-build-azd-extension.yml + parameters: + NameSuffix: LinuxARM64 + Pool: $(LINUXPOOL) + OSVmImage: $(LINUXVMIMAGE) + OS: linux + ImageKey: image + ValidateCrossCompile: false + AzdExtensionDirectory: ${{ ext.Directory }} + VersionSuffix: ${{ parameters.VersionSuffix }} + Variables: + BuildTarget: ${{ ext.SanitizedId }}-linux-arm64 + BuildOutputName: azd + SetExecutableBit: true + GOOS: linux + GOARCH: arm64 + + - template: /eng/pipelines/templates/jobs/cross-build-azd-extension.yml + parameters: + NameSuffix: MacARM64 + Pool: Azure Pipelines + OSVmImage: $(MACVMIMAGE) + OS: macOS + ImageKey: vmImage + ValidateCrossCompile: false + AzdExtensionDirectory: ${{ ext.Directory }} + VersionSuffix: ${{ parameters.VersionSuffix }} + Variables: + BuildTarget: ${{ ext.SanitizedId }}-darwin-arm64 + BuildOutputName: azd + SetExecutableBit: true + GOOS: darwin + GOARCH: arm64 + # CGO_ENABLED is required on MacOS to cross-compile pkg/outil/osversion + CGO_ENABLED: 1 + + - template: /eng/pipelines/templates/jobs/cross-build-azd-extension.yml + parameters: + NameSuffix: WindowsARM64 + Pool: $(WINDOWSPOOL) + OSVmImage: $(WINDOWSVMIMAGE) + OS: windows + ImageKey: image + ValidateCrossCompile: false + AzdExtensionDirectory: ${{ ext.Directory }} + VersionSuffix: ${{ parameters.VersionSuffix }} + Variables: + BuildTarget: ${{ ext.SanitizedId }}-windows-arm64.exe + BuildOutputName: azd.exe + GOOS: windows + GOARCH: arm64 + + # Package the unsigned binaries and publish a GitHub pre-release. + - job: Release + displayName: Package and publish pre-release + dependsOn: + - BuildExtension_Windows + - BuildExtension_Linux + - BuildExtension_Mac + - CrossBuildCLI_LinuxARM64 + - CrossBuildCLI_MacARM64 + - CrossBuildCLI_WindowsARM64 + pool: + name: $(LINUXPOOL) + image: $(LINUXVMIMAGE) + os: linux + steps: + - checkout: self + + - task: PowerShell@2 + displayName: Set extension version variable + inputs: + pwsh: true + targetType: filePath + filePath: eng/scripts/Set-ExtensionVersionVariable.ps1 + arguments: >- + -ExtensionDirectory ${{ ext.Directory }} + -Suffix '${{ parameters.VersionSuffix }}' + + - ${{ each suffix in split('windows-amd64.exe,linux-amd64,darwin-amd64,linux-arm64,darwin-arm64,windows-arm64.exe', ',') }}: + - task: DownloadPipelineArtifact@2 + displayName: Download ${{ ext.SanitizedId }}-${{ suffix }} + inputs: + artifact: ${{ ext.SanitizedId }}-${{ suffix }} + targetPath: bin/${{ ext.SanitizedId }}-${{ suffix }} + + - bash: | + set -euo pipefail + ext='${{ ext.SanitizedId }}' + mkdir -p release stage + # Copy only the named binary out of each artifact folder (artifacts + # may also contain a 1ES-injected _manifest directory). + for suffix in windows-amd64.exe linux-amd64 darwin-amd64 linux-arm64 darwin-arm64 windows-arm64.exe; do + cp "bin/$ext-$suffix/$ext-$suffix" stage/ + done + cp NOTICE.txt stage/ + cp '${{ ext.Directory }}/extension.yaml' stage/ + cd stage + chmod +x "$ext-linux-amd64" "$ext-linux-arm64" "$ext-darwin-amd64" "$ext-darwin-arm64" + # Windows + macOS ship as zip; Linux ships as tar.gz (matches manual release archive names) + zip "../release/$ext-darwin-amd64.zip" "$ext-darwin-amd64" NOTICE.txt extension.yaml + zip "../release/$ext-darwin-arm64.zip" "$ext-darwin-arm64" NOTICE.txt extension.yaml + zip "../release/$ext-windows-amd64.zip" "$ext-windows-amd64.exe" NOTICE.txt extension.yaml + zip "../release/$ext-windows-arm64.zip" "$ext-windows-arm64.exe" NOTICE.txt extension.yaml + tar -czvf "../release/$ext-linux-amd64.tar.gz" "$ext-linux-amd64" NOTICE.txt extension.yaml + tar -czvf "../release/$ext-linux-arm64.tar.gz" "$ext-linux-arm64" NOTICE.txt extension.yaml + cd .. + ls -la release + displayName: Package release archives + + - bash: | + set -euo pipefail + mkdir -p changelog + cat > changelog/CHANGELOG.md <<'EOF' + Automated nightly (alpha) build of ${{ ext.Id }}. + + - Version: `$(EXT_VERSION)` + - Commit: `$(Build.SourceVersion)` + + Install from the azd **dev** extension registry: + `https://aka.ms/azd/extensions/registry/dev` + EOF + cat changelog/CHANGELOG.md + displayName: Create release notes + + - bash: | + set -euo pipefail + tag="azd-ext-${{ ext.SanitizedId }}_$(EXT_VERSION)" + echo "Release tag: $tag" + # If a release for this tag already exists (e.g., build retry with the + # same BuildNumber), delete it so this step is fully idempotent. + if gh release view "$tag" --repo "$(Build.Repository.Name)" > /dev/null 2>&1; then + echo "Release $tag already exists - deleting before recreating." + gh release delete "$tag" --repo "$(Build.Repository.Name)" --yes --cleanup-tag + fi + # --target pins the tag to the exact built commit so the next + # nightly's change detection diffs from what was actually published. + gh release create "$tag" \ + --repo "$(Build.Repository.Name)" \ + --target "$(Build.SourceVersion)" \ + --title "$tag" \ + --notes-file changelog/CHANGELOG.md \ + --prerelease + gh release upload "$tag" release/* --repo "$(Build.Repository.Name)" + displayName: Create GitHub pre-release + # Defense in depth: only publish on scheduled (nightly) or manual runs. + condition: and(succeeded(), in(variables['Build.Reason'], 'Schedule', 'Manual')) + env: + GH_TOKEN: $(azuresdk-github-pat) + + # -------------------------------------------------------------------------- + # Update the dev registry for every changed extension in a single PR. + # Runs only when at least one extension changed and no build stage failed. + # -------------------------------------------------------------------------- + - stage: UpdateDevRegistry + displayName: Update dev registry + dependsOn: + - Prepare + - ${{ each ext in parameters.Extensions }}: + - Build_${{ replace(ext.SanitizedId, '-', '_') }} + condition: >- + and( + not(failed()), + not(canceled()), + in(variables['Build.Reason'], 'Schedule', 'Manual'), + eq(dependencies.Prepare.outputs['DetectChanges.detect.AnyChanges'], 'true') + ) + variables: + - template: /eng/pipelines/templates/variables/globals.yml + - template: /eng/pipelines/templates/variables/image.yml + - ${{ each ext in parameters.Extensions }}: + - name: ShouldBuild_${{ replace(ext.SanitizedId, '-', '_') }} + value: $[ stageDependencies.Prepare.DetectChanges.outputs['detect.ShouldBuild_${{ replace(ext.SanitizedId, '-', '_') }}'] ] + jobs: + - job: UpdateRegistry + displayName: Publish changed extensions to dev registry + pool: + name: $(LINUXPOOL) + image: $(LINUXVMIMAGE) + os: linux + steps: + - checkout: self + + - template: /eng/pipelines/templates/steps/setup-go.yml + + - bash: | + set -euo pipefail + curl -fsSL https://aka.ms/install-azd.sh | bash -s -- --verbose + azd version + displayName: Install azd + + - bash: | + set -euo pipefail + azd ext install microsoft.azd.extensions --source azd + displayName: Install microsoft.azd.extensions + + - ${{ each ext in parameters.Extensions }}: + - bash: | + set -euo pipefail + # Recompose the nightly version (mirrors Set-ExtensionVersionVariable.ps1): + # append the suffix with a dot when the base already has a pre-release + # segment, otherwise with a hyphen. + base=$(tr -d '[:space:]' < '${{ ext.Directory }}/version.txt') + suffix='${{ parameters.VersionSuffix }}' + case "$base" in + *-*) version="$base.$suffix" ;; + *) version="$base-$suffix" ;; + esac + echo "Publishing ${{ ext.Id }} version $version to dev registry" + cd '${{ ext.Directory }}' + azd x publish \ + --registry "../registry.dev.json" \ + --repo "$(Build.Repository.Name)" \ + --version "$version" + displayName: Publish ${{ ext.Id }} to dev registry + condition: eq(variables['ShouldBuild_${{ replace(ext.SanitizedId, '-', '_') }}'], 'true') + env: + GH_TOKEN: $(azuresdk-github-pat) + + - template: /eng/common/pipelines/templates/steps/create-pull-request.yml + parameters: + PRBranchName: ext-dev-registry-nightly/$(Build.BuildNumber) + CommitMsg: "[nightly] Dev registry update for azd extensions ($(Build.BuildNumber))" + PRTitle: "[nightly] Dev registry update for azd extensions" + PRBody: >- + Automated nightly update of the azd dev extension registry + (`cli/azd/extensions/registry.dev.json`) for extensions that + changed since their last nightly build (build `$(Build.BuildNumber)`). diff --git a/eng/scripts/Set-ExtensionVersionVariable.ps1 b/eng/scripts/Set-ExtensionVersionVariable.ps1 index 34277f57182..49a303a110e 100644 --- a/eng/scripts/Set-ExtensionVersionVariable.ps1 +++ b/eng/scripts/Set-ExtensionVersionVariable.ps1 @@ -1,7 +1,25 @@ param( - [string] $ExtensionDirectory + [string] $ExtensionDirectory, + # Optional pre-release suffix (without the leading '-'). When provided it is + # appended to the base version, e.g. -Suffix 'alpha.202606150300' produces + # '-alpha.202606150300'. Used by the nightly extension release pipeline. + [string] $Suffix ) -$extVersion = Get-Content "$ExtensionDirectory/version.txt" +$extVersion = (Get-Content "$ExtensionDirectory/version.txt" -Raw).Trim() + +if ($Suffix) { + # If the base version already has a pre-release segment (contains '-'), + # extend it with dot-separated identifiers to keep valid semver ordering + # (e.g. '0.1.39-preview' + 'alpha.202606150300' => '0.1.39-preview.alpha.202606150300'). + # Otherwise start the pre-release segment with a hyphen + # (e.g. '0.5.0' + 'alpha.202606150300' => '0.5.0-alpha.202606150300'). + if ($extVersion -match '-') { + $extVersion = "$extVersion.$Suffix" + } else { + $extVersion = "$extVersion-$Suffix" + } +} + Write-Host "Extension Version: $extVersion" Write-Host "##vso[task.setvariable variable=EXT_VERSION;]$extVersion"