diff --git a/cli/azd/docs/extensions/extension-framework.md b/cli/azd/docs/extensions/extension-framework.md index 70ffe5b178c..048318e4629 100644 --- a/cli/azd/docs/extensions/extension-framework.md +++ b/cli/azd/docs/extensions/extension-framework.md @@ -84,6 +84,15 @@ azd extension source add -n dev -t url -l "https://aka.ms/azd/extensions/registr Extensions installed from the dev registry are automatically promoted to the main registry when a newer version becomes available there. See the [Dev/Experimental Extension Registry](./extension-resolution-and-versioning.md#devexperimental-extension-registry) section for full details on stability expectations, submission guidelines, promotion behavior, and troubleshooting. +A separate **nightly** registry distributes always-latest, automatically built snapshots of first-party extensions (signed on Windows/macOS, built from `main`). To opt in: + +```bash +# Add a new extension source name 'nightly' to your `azd` configuration. +azd extension source add -n nightly -t url -l "https://raw.githubusercontent.com/Azure/azure-dev/nightly/cli/azd/extensions/registry.nightly.json" +``` + +See the [Nightly Extension Registry](./extension-resolution-and-versioning.md#nightly-extension-registry) section for version semantics, promotion behavior, and caveats. + #### `azd extension source list` Displays a list of installed extension sources. diff --git a/cli/azd/docs/extensions/extension-resolution-and-versioning.md b/cli/azd/docs/extensions/extension-resolution-and-versioning.md index f42a24fea0a..c4eb59d3562 100644 --- a/cli/azd/docs/extensions/extension-resolution-and-versioning.md +++ b/cli/azd/docs/extensions/extension-resolution-and-versioning.md @@ -618,6 +618,54 @@ If the dev registry URL is unreachable (network issue, DNS failure), operations azd extension source remove dev ``` +## Nightly Extension Registry + +The nightly registry contains **automatically built, always-latest** development snapshots of first-party extensions. Each scheduled pipeline run rebuilds an extension from `main`, signs the Windows and macOS binaries, uploads them to an always-latest storage folder, and updates a single entry in the nightly registry. Installing a nightly always gives you the most recent nightly build available at that time. + +| Property | Main Registry | Nightly Registry | +|----------|---------------|------------------| +| URL | `https://aka.ms/azd/extensions/registry` | `https://raw.githubusercontent.com/Azure/azure-dev/nightly/cli/azd/extensions/registry.nightly.json` | +| Source file | `cli/azd/extensions/registry.json` (on `main`) | `cli/azd/extensions/registry.nightly.json` (on the `nightly` branch) | +| Source name | `azd` (built-in default) | `nightly` (opt-in) | +| Version shape | `1.2.3` | `1.2.3-nightly.` (or `1.2.3-preview.nightly.`) | +| Signed binaries | Yes | Windows/macOS signed; Linux unsigned | +| History retained | Yes | No — only the latest nightly per extension | +| Support | Covered by Azure support | **Not covered** | + +> [!CAUTION] +> Nightly extensions are built from `main` and come with **no stability guarantees**. Only the current nightly version is retained - older nightly versions are not installable. + +### Adding the Nightly Registry + +The nightly registry must be added, manually. To opt in: + +```bash +# Add the nightly registry as a source named "nightly" +azd extension source add -n nightly -t url -l "https://raw.githubusercontent.com/Azure/azure-dev/nightly/cli/azd/extensions/registry.nightly.json" +``` + +Then, to install a nightly-built extension: + +```bash +azd extension install --source nightly +``` + +To remove the nightly registry later: + +```bash +azd extension source remove nightly +``` + +### Upgrade and Nightly→Main Promotion + +Nightly versions use semver prerelease labels, so the standard `azd extension upgrade` flow works: + +- A newer nightly (higher build id, or a higher base version) supersedes an older one, so `azd extension upgrade` pulls the latest nightly. +- When the extension ships a **stable** release whose base version matches your nightly (for example stable `1.2.3` versus `1.2.3-nightly.200`), the stable release outranks the nightly and you are **automatically promoted** to the `azd` registry on your next upgrade. + +> [!NOTE] +> If your nightly was built from a **prerelease** base (for example `1.2.3-preview.nightly.60`), it sorts **above** the matching stable prerelease `1.2.3-preview`. In that case you are not promoted until the stable registry advances to a higher base version. This is expected semver precedence behavior. + ## Related Documentation | Document | Description | diff --git a/cli/azd/pkg/extensions/nightly_versioning_test.go b/cli/azd/pkg/extensions/nightly_versioning_test.go new file mode 100644 index 00000000000..f2f21569684 --- /dev/null +++ b/cli/azd/pkg/extensions/nightly_versioning_test.go @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package extensions + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// Test_UpdateChecker_NightlyVersions verifies that the nightly version scheme +// (X.Y.Z-nightly. and X.Y.Z-preview.nightly.) produces the +// expected "has update" results through the same semver comparison azd uses at +// runtime. These pin the upgrade behavior we rely on for nightlies. +func Test_UpdateChecker_NightlyVersions(t *testing.T) { + tests := []struct { + name string + installed string + available []string // available versions in the source (latest is the max semver) + wantUpdate bool + }{ + { + name: "newer nightly supersedes older nightly", + installed: "1.2.3-nightly.100", + available: []string{"1.2.3-nightly.100", "1.2.3-nightly.200"}, + wantUpdate: true, + }, + { + name: "same nightly is not an update", + installed: "1.2.3-nightly.222", + available: []string{"1.2.3-nightly.222"}, + wantUpdate: false, + }, + { + name: "base version bump supersedes older nightly", + installed: "1.2.3-nightly.999", + available: []string{"1.2.3-nightly.999", "1.2.4-nightly.1"}, + wantUpdate: true, + }, + { + name: "stable release supersedes nightly of same base", + installed: "1.2.3-nightly.200", + available: []string{"1.2.3-nightly.200", "1.2.3"}, + wantUpdate: true, + }, + { + // Documents the prerelease-base caveat: a nightly built off a + // prerelease base sorts ABOVE the matching stable prerelease, so a + // user on the stable preview sees the nightly as an update. + name: "nightly off preview base outranks stable preview", + installed: "1.2.3-preview", + available: []string{"1.2.3-preview", "1.2.3-preview.nightly.60"}, + wantUpdate: true, + }, + { + name: "newer preview nightly supersedes older preview nightly", + installed: "1.2.3-preview.nightly.50", + available: []string{"1.2.3-preview.nightly.50", "1.2.3-preview.nightly.60"}, + wantUpdate: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("AZD_CONFIG_DIR", t.TempDir()) + + cacheManager, err := NewRegistryCacheManager() + require.NoError(t, err) + + ctx := t.Context() + sourceName := "nightly" + + versions := make([]ExtensionVersion, 0, len(tt.available)) + for _, v := range tt.available { + versions = append(versions, ExtensionVersion{Version: v}) + } + + err = cacheManager.Set(ctx, sourceName, []*ExtensionMetadata{ + { + Id: "test.extension", + DisplayName: "Test Extension", + Versions: versions, + }, + }) + require.NoError(t, err) + + updateChecker := NewUpdateChecker(cacheManager) + + result, err := updateChecker.CheckForUpdate(ctx, &Extension{ + Id: "test.extension", + DisplayName: "Test Extension", + Version: tt.installed, + Source: sourceName, + }) + require.NoError(t, err) + require.Equal(t, tt.wantUpdate, result.HasUpdate) + }) + } +} + +// Test_ResolveUpgradeSource_NightlyPromotion verifies how a nightly-sourced +// install promotes (or not) to the stable "azd" registry given the chosen +// version strings. Promotion happens only when the stable registry's latest +// version is strictly greater (semver) than the installed nightly's. +func Test_ResolveUpgradeSource_NightlyPromotion(t *testing.T) { + makeExt := func(source string, versions ...string) *ExtensionMetadata { + ext := &ExtensionMetadata{Id: "test.extension", Source: source} + for _, v := range versions { + ext.Versions = append(ext.Versions, ExtensionVersion{Version: v}) + } + return ext + } + + tests := []struct { + name string + nightlyLatest string + mainLatest string // empty => extension not in the stable registry + wantPromotion bool + wantSource string + }{ + { + name: "stable release promotes nightly of same base", + nightlyLatest: "1.2.3-nightly.200", + mainLatest: "1.2.3", + wantPromotion: true, + wantSource: MainRegistryName, + }, + { + name: "nightly off preview base stays on nightly (outranks stable preview)", + nightlyLatest: "1.2.3-preview.nightly.60", + mainLatest: "1.2.3-preview", + wantPromotion: false, + wantSource: "nightly", + }, + { + name: "higher stable base promotes preview nightly", + nightlyLatest: "1.2.3-preview.nightly.60", + mainLatest: "1.2.4", + wantPromotion: true, + wantSource: MainRegistryName, + }, + { + name: "no stable entry keeps user on nightly", + nightlyLatest: "1.2.3-nightly.200", + mainLatest: "", + wantPromotion: false, + wantSource: "nightly", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + installed := &Extension{Id: "test.extension", Source: "nightly"} + + allMatches := []*ExtensionMetadata{makeExt("nightly", tt.nightlyLatest)} + if tt.mainLatest != "" { + allMatches = append(allMatches, makeExt(MainRegistryName, tt.mainLatest)) + } + + result := ResolveUpgradeSource(installed, allMatches, "") + require.NotNil(t, result) + require.Equal(t, tt.wantPromotion, result.IsPromotion) + require.Equal(t, tt.wantSource, result.NewSource) + }) + } +} diff --git a/eng/pipelines/templates/stages/publish-extension.yml b/eng/pipelines/templates/stages/publish-extension.yml index 67d73873e5a..ada56ed662c 100644 --- a/eng/pipelines/templates/stages/publish-extension.yml +++ b/eng/pipelines/templates/stages/publish-extension.yml @@ -20,7 +20,7 @@ stages: eq('Manual', variables['BuildReasonOverride']), and( eq('', variables['BuildReasonOverride']), - eq('Manual', variables['Build.Reason']) + in(variables['Build.Reason'], 'Manual', 'Schedule') ) ) ) @@ -28,7 +28,15 @@ stages: variables: - template: /eng/pipelines/templates/variables/image.yml - template: /eng/pipelines/templates/variables/globals.yml - - ${{ if eq(parameters.PublishToDevRegistry, true) }}: + # + # Scheduled runs publish our nightly builds to a dedicated registry on the + # 'nightly' branch (direct push, no PR). + # + # Manual runs publish to the stable or dev registry via a pull request. + - ${{ if eq(variables['Build.Reason'], 'Schedule') }}: + - name: ExtensionRegistryFile + value: registry.nightly.json + - ${{ elseif eq(parameters.PublishToDevRegistry, true) }}: - name: ExtensionRegistryFile value: registry.dev.json - name: ExtensionRegistryPullRequestPrefix @@ -50,7 +58,13 @@ stages: succeeded(), ne('true', variables['Skip.Publish']) ) - environment: package-publish + # Nightly publishes unattended, so it uses the no-approval 'none' + # environment and is not a governed production release. Manual releases + # go through the approval-gated package-publish environment. + ${{ if eq(variables['Build.Reason'], 'Schedule') }}: + environment: none + ${{ else }}: + environment: package-publish pool: name: azsdk-pool @@ -59,7 +73,10 @@ stages: templateContext: type: releaseJob - isProduction: true + ${{ if eq(variables['Build.Reason'], 'Schedule') }}: + isProduction: false + ${{ else }}: + isProduction: true inputs: - input: pipelineArtifact artifactName: release @@ -77,19 +94,31 @@ stages: parameters: Use1ESArtifactTask: true - - pwsh: | - # Initial upload locations - $publishUploadLocations = '${{ parameters.SanitizedExtensionId }}/$(EXT_VERSION)' + - ${{ if eq(variables['Build.Reason'], 'Schedule') }}: + - pwsh: | + # Nightly: always-latest folder, overwritten each run. + $publishUploadLocations = '${{ parameters.SanitizedExtensionId }}/nightly' + Write-Host "Setting StorageUploadLocations to $publishUploadLocations" + Write-Host "###vso[task.setvariable variable=StorageUploadLocations]$publishUploadLocations" + displayName: Set StorageUploadLocations (nightly) + - ${{ else }}: + - pwsh: | + # Initial upload locations + $publishUploadLocations = '${{ parameters.SanitizedExtensionId }}/$(EXT_VERSION)' - Write-Host "Setting StorageUploadLocations to $publishUploadLocations" - Write-Host "###vso[task.setvariable variable=StorageUploadLocations]$publishUploadLocations" - displayName: Set StorageUploadLocations + Write-Host "Setting StorageUploadLocations to $publishUploadLocations" + Write-Host "###vso[task.setvariable variable=StorageUploadLocations]$publishUploadLocations" + displayName: Set StorageUploadLocations - template: /eng/pipelines/templates/steps/publish-extension.yml parameters: PublishUploadLocations: $(StorageUploadLocations) TagPrefix: azd-ext-${{ parameters.SanitizedExtensionId }} TagVersion: $(EXT_VERSION) + # Nightly builds publish to storage only; no GitHub release since that'd just be a ton of clutter. + # Nightly extension consumers just grab it from blob storage. + ${{ if eq(variables['Build.Reason'], 'Schedule') }}: + CreateGitHubRelease: false # Updates the selected extension registry after the GitHub Release exists. # Production registry updates also refresh global command snapshots. @@ -125,33 +154,113 @@ stages: azd ext install microsoft.azd.extensions --source azd displayName: Install microsoft.azd.extensions - - bash: | - set -euo pipefail - cd "${{ parameters.AzdExtensionDirectory }}" - azd x publish \ - --registry "../$(ExtensionRegistryFile)" \ - --repo "$(Build.Repository.Name)" \ - --version "$(EXT_VERSION)" - displayName: Update $(ExtensionRegistryFile) (azd x publish) - env: - GH_TOKEN: $(azuresdk-github-pat) - - - ${{ if ne(parameters.PublishToDevRegistry, true) }}: + - ${{ if eq(variables['Build.Reason'], 'Schedule') }}: + # Nightly: there is no GitHub release, so publish from the local + # release artifacts and direct-push the nightly registry to the + # 'nightly' branch with a fetch/reset/retry loop so concurrent + # extension pipelines don't clobber each other. + - task: DownloadPipelineArtifact@2 + displayName: Download release artifacts + inputs: + artifactName: release + targetPath: $(Build.ArtifactStagingDirectory)/release + - bash: | set -euo pipefail - cd cli/azd - go build . - go test ./cmd -run 'TestFigSpec|TestUsage' - displayName: Refresh Fig/Usage snapshots + + EXT_DIR="${{ parameters.AzdExtensionDirectory }}" + EXT_ID="${{ parameters.AzdExtensionId }}" + SANITIZED="${{ parameters.SanitizedExtensionId }}" + VERSION="$(EXT_VERSION)" + STATIC_HOST="$(publish-storage-static-host)" + ARTIFACTS_DIR="$(Build.ArtifactStagingDirectory)/release" + REPO="$(Build.Repository.Name)" + REPO_URL="https://$(azuresdk-github-pat)@github.com/${REPO}.git" + WORKTREE="$(Agent.TempDirectory)/nightly-registry" + REGISTRY_REL="cli/azd/extensions/registry.nightly.json" + + git config --global user.email "azuresdk@microsoft.com" + git config --global user.name "azure-sdk" + + max=5 + attempt=1 + while [ "$attempt" -le "$max" ]; do + echo "== Nightly registry publish attempt ${attempt}/${max}" + + rm -rf "$WORKTREE" + git clone --depth 1 --branch nightly "$REPO_URL" "$WORKTREE" + + # Normal extension publishing uses a GitHub Release as the artifact + # source, so azd x publish can write release asset URLs directly to + # the registry. Nightlies intentionally publish only to the storage + # account, so --artifacts is the closest supported mode; it computes + # the same checksums from local files but writes local file paths as + # artifact URLs. The normalization step below rewrites those paths to + # the public storage URLs. + ( cd "$EXT_DIR" && azd x publish \ + --registry "$WORKTREE/$REGISTRY_REL" \ + --artifacts "${ARTIFACTS_DIR}/*.zip,${ARTIFACTS_DIR}/*.tar.gz" \ + --version "$VERSION" ) + + # Rewrite artifact URLs to their public storage location and prune to + # only this nightly version (storage is overwritten). + pwsh -NoProfile -File eng/scripts/Update-NightlyExtensionRegistry.ps1 \ + -RegistryPath "$WORKTREE/$REGISTRY_REL" \ + -ExtensionId "$EXT_ID" \ + -Version "$VERSION" \ + -SanitizedExtensionId "$SANITIZED" \ + -StaticHost "$STATIC_HOST" + + cd "$WORKTREE" + git add "$REGISTRY_REL" + if git diff --cached --quiet; then + echo "No changes to nightly registry; done." + exit 0 + fi + git commit -m "[${EXT_ID}] Nightly registry update for ${VERSION}" + + if git push origin HEAD:nightly; then + echo "Nightly registry updated." + exit 0 + fi + + echo "Push rejected (likely concurrent update); retrying from fresh tip." + cd "$(Build.SourcesDirectory)" + attempt=$((attempt + 1)) + done + + echo "Failed to push nightly registry after ${max} attempts." >&2 + exit 1 + displayName: Publish nightly registry (azd x publish + push) + + - ${{ else }}: + - bash: | + set -euo pipefail + cd "${{ parameters.AzdExtensionDirectory }}" + azd x publish \ + --registry "../$(ExtensionRegistryFile)" \ + --repo "$(Build.Repository.Name)" \ + --version "$(EXT_VERSION)" + displayName: Update $(ExtensionRegistryFile) (azd x publish) env: - UPDATE_SNAPSHOTS: "true" - - - template: /eng/common/pipelines/templates/steps/create-pull-request.yml - parameters: - PRBranchName: $(ExtensionRegistryPullRequestPrefix)/${{ parameters.SanitizedExtensionId }}/$(EXT_VERSION)-$(Build.BuildId) - CommitMsg: "[${{ parameters.AzdExtensionId }}] $(ExtensionRegistryPullRequestTitle) for $(EXT_VERSION)" - PRTitle: "[${{ parameters.AzdExtensionId }}] $(ExtensionRegistryPullRequestTitle) for $(EXT_VERSION)" - PRBody: >- - $(ExtensionRegistryPullRequestTitle) for the - [azd-ext-${{ parameters.SanitizedExtensionId }}_$(EXT_VERSION)](https://github.com/$(Build.Repository.Name)/releases/tag/azd-ext-${{ parameters.SanitizedExtensionId }}_$(EXT_VERSION)) - release. + GH_TOKEN: $(azuresdk-github-pat) + + - ${{ if ne(parameters.PublishToDevRegistry, true) }}: + - bash: | + set -euo pipefail + cd cli/azd + go build . + go test ./cmd -run 'TestFigSpec|TestUsage' + displayName: Refresh Fig/Usage snapshots + env: + UPDATE_SNAPSHOTS: "true" + + - template: /eng/common/pipelines/templates/steps/create-pull-request.yml + parameters: + PRBranchName: $(ExtensionRegistryPullRequestPrefix)/${{ parameters.SanitizedExtensionId }}/$(EXT_VERSION)-$(Build.BuildId) + CommitMsg: "[${{ parameters.AzdExtensionId }}] $(ExtensionRegistryPullRequestTitle) for $(EXT_VERSION)" + PRTitle: "[${{ parameters.AzdExtensionId }}] $(ExtensionRegistryPullRequestTitle) for $(EXT_VERSION)" + PRBody: >- + $(ExtensionRegistryPullRequestTitle) for the + [azd-ext-${{ parameters.SanitizedExtensionId }}_$(EXT_VERSION)](https://github.com/$(Build.Repository.Name)/releases/tag/azd-ext-${{ parameters.SanitizedExtensionId }}_$(EXT_VERSION)) + release. diff --git a/eng/pipelines/templates/stages/release-azd-extension.yml b/eng/pipelines/templates/stages/release-azd-extension.yml index a2f656bee85..3aab1281be7 100644 --- a/eng/pipelines/templates/stages/release-azd-extension.yml +++ b/eng/pipelines/templates/stages/release-azd-extension.yml @@ -64,7 +64,7 @@ stages: # Codeql.BuildIdentifier: cli_darwin ${{ if eq(variables['Build.Reason'], 'Schedule') }}: - # Only run this build during scheduled pipeline executions + # Only run this build during scheduled pipeline executions. MacAppleSilicon: Pool: Azure Pipelines OSVmImage: $(MACVMIMAGEM1) @@ -78,8 +78,10 @@ stages: SetExecutableBit: true AZURE_DEV_CI_OS: mac-arm64 - # Only sign and release on manual builds from internal - - ${{ if and(eq(variables['System.TeamProject'], 'internal'), eq(variables['Build.Reason'], 'Manual')) }}: + # Sign and release on manual builds (stable/dev release) and scheduled builds + # (nightly) from internal. Nightly builds reuse the same sign + package + publish path; + # publish-extension.yml branches its behavior on Build.Reason. + - ${{ if and(eq(variables['System.TeamProject'], 'internal'), in(variables['Build.Reason'], 'Manual', 'Schedule')) }}: - template: /eng/pipelines/templates/stages/sign-extension.yml parameters: SanitizedExtensionId: ${{ parameters.SanitizedExtensionId }} diff --git a/eng/pipelines/templates/stages/sign-extension.yml b/eng/pipelines/templates/stages/sign-extension.yml index c5715ba1ff0..c0caaa21023 100644 --- a/eng/pipelines/templates/stages/sign-extension.yml +++ b/eng/pipelines/templates/stages/sign-extension.yml @@ -43,7 +43,8 @@ stages: -DestinationPath mac/${{ parameters.SanitizedExtensionId }}-darwin-arm64.zip displayName: Package mac binary for signing - - ${{ if and(in(variables['Build.Reason'], 'IndividualCI', 'BatchedCI', 'Manual'), eq(variables['Build.Repository.Name'], 'Azure/azure-dev')) }}: + # Sign stable/CI release builds and scheduled (nightly) builds. + - ${{ if and(in(variables['Build.Reason'], 'IndividualCI', 'BatchedCI', 'Manual', 'Schedule'), eq(variables['Build.Repository.Name'], 'Azure/azure-dev')) }}: - template: pipelines/steps/azd-cli-mac-signing.yml@azure-sdk-build-tools parameters: MacPath: mac @@ -117,7 +118,8 @@ stages: Get-Childitem -Recurse win/ | Select-Object -Property Length,FullName displayName: Prepare assets for signing - - ${{ if and(in(variables['Build.Reason'], 'IndividualCI', 'BatchedCI', 'Manual'), eq(variables['Build.Repository.Name'], 'Azure/azure-dev')) }}: + # Sign stable/CI release builds and scheduled (nightly) builds. + - ${{ if and(in(variables['Build.Reason'], 'IndividualCI', 'BatchedCI', 'Manual', 'Schedule'), eq(variables['Build.Repository.Name'], 'Azure/azure-dev')) }}: - template: pipelines/steps/azd-cli-win-signing.yml@azure-sdk-build-tools parameters: WinPath: win diff --git a/eng/pipelines/templates/steps/publish-extension.yml b/eng/pipelines/templates/steps/publish-extension.yml index 6608941e932..8bf6cf942e2 100644 --- a/eng/pipelines/templates/steps/publish-extension.yml +++ b/eng/pipelines/templates/steps/publish-extension.yml @@ -8,35 +8,40 @@ parameters: type: string - name: TagVersion type: string + # When false (nightly), skip the GitHub release and only upload to storage. + - name: CreateGitHubRelease + type: boolean + default: true steps: - # This step must run first because a duplicated tag means we don't need to - # continue with any of the subsequent steps. - - pwsh: | - $tag = "${{ parameters.TagPrefix }}_${{ parameters.TagVersion}}" - Write-Host "Release tag: $tag" + - ${{ if eq(parameters.CreateGitHubRelease, true) }}: + # This step must run first because a duplicated tag means we don't need to + # continue with any of the subsequent steps. + - pwsh: | + $tag = "${{ parameters.TagPrefix }}_${{ parameters.TagVersion}}" + Write-Host "Release tag: $tag" + + # Check for tag using gh API + $existingTag = gh api /repos/$(Build.Repository.Name)/tags | ConvertFrom-Json | Where-Object { $_.name -eq $tag } + if ($existingTag) { + Write-Host "Tag $tag already exists. Exiting." + exit 1 + } - # Check for tag using gh API - $existingTag = gh api /repos/$(Build.Repository.Name)/tags | ConvertFrom-Json | Where-Object { $_.name -eq $tag } - if ($existingTag) { - Write-Host "Tag $tag already exists. Exiting." + gh release view $tag --repo $(Build.Repository.Name) + if ($LASTEXITCODE -eq 0) { + Write-Host "Release ($tag) already exists. Exiting." exit 1 - } + } - gh release view $tag --repo $(Build.Repository.Name) - if ($LASTEXITCODE -eq 0) { - Write-Host "Release ($tag) already exists. Exiting." - exit 1 - } + Write-Host "##vso[task.setvariable variable=GH_RELEASE_TAG;]$tag" - Write-Host "##vso[task.setvariable variable=GH_RELEASE_TAG;]$tag" - - # Exit with 0 (otherwise $LASTEXITCODE will not be 0 and the pipeline - # will fail) - exit 0 - displayName: Check for existing GitHub release - env: - GH_TOKEN: $(azuresdk-github-pat) + # Exit with 0 (otherwise $LASTEXITCODE will not be 0 and the pipeline + # will fail) + exit 0 + displayName: Check for existing GitHub release + env: + GH_TOKEN: $(azuresdk-github-pat) - pwsh: | Remove-Item -Path release/_manifest -Recurse -Force @@ -44,25 +49,26 @@ steps: Get-ChildItem -Recurse release/ | Select-Object -Property Length,FullName displayName: Remove _manifest folder - - pwsh: | - $version = "${{ parameters.TagVersion }}" - $createArgs = @( - "$(GH_RELEASE_TAG)", - "--title", "$(GH_RELEASE_TAG)", - "--notes-file", "changelog/CHANGELOG.md", - "--repo", "$(Build.Repository.Name)" - ) + - ${{ if eq(parameters.CreateGitHubRelease, true) }}: + - pwsh: | + $version = "${{ parameters.TagVersion }}" + $createArgs = @( + "$(GH_RELEASE_TAG)", + "--title", "$(GH_RELEASE_TAG)", + "--notes-file", "changelog/CHANGELOG.md", + "--repo", "$(Build.Repository.Name)" + ) - if ($version -match "^0\." -or $version -match "-(alpha|beta|preview)") { - $createArgs += "--prerelease" - } + if ($version -match "^0\." -or $version -match "-(alpha|beta|preview)") { + $createArgs += "--prerelease" + } - gh release create @createArgs + gh release create @createArgs - gh release upload $(GH_RELEASE_TAG) release/* --repo $(Build.Repository.Name) - displayName: Create GitHub Release and upload artifacts - env: - GH_TOKEN: $(azuresdk-github-pat) + gh release upload $(GH_RELEASE_TAG) release/* --repo $(Build.Repository.Name) + displayName: Create GitHub Release and upload artifacts + env: + GH_TOKEN: $(azuresdk-github-pat) - task: AzurePowerShell@5 displayName: Upload release to storage account @@ -77,7 +83,7 @@ steps: Get-ChildItem release/ foreach ($folder in $uploadLocations) { Write-Host "Upload to ${{ parameters.StorageContainerName }}/azd/extensions/$folder" - azcopy copy "release/*" "$(publish-storage-location)/${{ parameters.StorageContainerName }}/azd/extensions/$folder" + azcopy copy "release/*" "$(publish-storage-location)/${{ parameters.StorageContainerName }}/azd/extensions/$folder" --overwrite=true if ($LASTEXITCODE) { Write-Error "Upload failed" exit 1 diff --git a/eng/scripts/Set-ExtensionVersionVariable.ps1 b/eng/scripts/Set-ExtensionVersionVariable.ps1 index 34277f57182..af32b52a2c9 100644 --- a/eng/scripts/Set-ExtensionVersionVariable.ps1 +++ b/eng/scripts/Set-ExtensionVersionVariable.ps1 @@ -1,7 +1,31 @@ param( - [string] $ExtensionDirectory + [string] $ExtensionDirectory, + # Defaults to the pipeline-provided values so the script is unit-testable. + [string] $BuildReason = $env:BUILD_REASON, + [string] $BuildId = $env:BUILD_BUILDID ) -$extVersion = Get-Content "$ExtensionDirectory/version.txt" +$extVersion = (Get-Content "$ExtensionDirectory/version.txt").Trim() + +# On scheduled (nightly) runs, append a semver-valid prerelease suffix so each +# nightly sorts above the previous one (numeric build id) while still sorting +# below the matching stable release for non-prerelease base versions. The build +# id keeps all matrix jobs in a single run on the same version even if the run +# crosses midnight, and guarantees a re-run produces a distinct version. +if ($BuildReason -eq 'Schedule') { + if ([string]::IsNullOrWhiteSpace($BuildId)) { + throw "BuildId is required for nightly versioning but was empty (expected Build.BuildId)." + } + + if ($extVersion.Contains('-')) { + # Base already has a prerelease label (e.g. 1.2.3-preview): extend it. + $extVersion = "$extVersion.nightly.$BuildId" + } + else { + # Stable base (e.g. 1.2.3): add the prerelease label. + $extVersion = "$extVersion-nightly.$BuildId" + } +} + Write-Host "Extension Version: $extVersion" Write-Host "##vso[task.setvariable variable=EXT_VERSION;]$extVersion" diff --git a/eng/scripts/Update-NightlyExtensionRegistry.ps1 b/eng/scripts/Update-NightlyExtensionRegistry.ps1 new file mode 100644 index 00000000000..796c3a58875 --- /dev/null +++ b/eng/scripts/Update-NightlyExtensionRegistry.ps1 @@ -0,0 +1,73 @@ +<# +.SYNOPSIS + Normalizes a nightly extension registry entry after `azd x publish --artifacts`. + +.DESCRIPTION + `azd x publish --artifacts` records the local file path of each artifact as its + registry URL because local files have no remote URL. This script rewrites those + URLs to the public Azure Storage location for the extension's always-latest + nightly folder, and prunes the extension to only the current nightly version + (the nightly storage folder is overwritten in place each run, so older registry + entries would point at replaced blobs with mismatched checksums). + + Only the named extension is modified; other extensions in the registry are left + untouched so concurrent nightly pipelines preserve each other's entries. + +.PARAMETER RegistryPath + Path to the registry.nightly.json file to update. + +.PARAMETER ExtensionId + The extension id (e.g. azure.ai.agents) that was just published. + +.PARAMETER Version + The nightly version that was just published (e.g. 1.2.3-nightly.98765). + +.PARAMETER SanitizedExtensionId + The dash-form extension id used in storage paths (e.g. azure-ai-agents). + +.PARAMETER StaticHost + The static web host base URL (e.g. https://azuresdkartifacts.z5.web.core.windows.net). +#> +param( + [Parameter(Mandatory)][string] $RegistryPath, + [Parameter(Mandatory)][string] $ExtensionId, + [Parameter(Mandatory)][string] $Version, + [Parameter(Mandatory)][string] $SanitizedExtensionId, + [Parameter(Mandatory)][string] $StaticHost +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$registry = Get-Content -Path $RegistryPath -Raw | ConvertFrom-Json + +$ext = $registry.extensions | Where-Object { $_.id -eq $ExtensionId } +if (-not $ext) { + throw "Extension '$ExtensionId' not found in '$RegistryPath' after publish." +} + +$versionEntry = $ext.versions | Where-Object { $_.version -eq $Version } +if (-not $versionEntry) { + throw "Version '$Version' not found for extension '$ExtensionId' after publish." +} + +# Trim a trailing slash from the host so the joined URL is well-formed. +$baseUrl = "$($StaticHost.TrimEnd('/'))/azd/extensions/$SanitizedExtensionId/nightly" + +# Rewrite each artifact's local file path to its public storage URL. The file +# name is preserved so it matches what was uploaded to the nightly folder. +foreach ($prop in $versionEntry.artifacts.PSObject.Properties) { + $artifact = $prop.Value + $fileName = Split-Path -Path $artifact.url -Leaf + $artifact.url = "$baseUrl/$fileName" + Write-Host " $($prop.Name) -> $($artifact.url)" +} + +# Prune to only the current nightly version (storage is overwritten in place). +$ext.versions = @($versionEntry) + +# 100 is deep enough for the nested artifacts -> os/arch -> metadata structure. +$json = $registry | ConvertTo-Json -Depth 100 +Set-Content -Path $RegistryPath -Value $json + +Write-Host "Updated nightly registry entry for '$ExtensionId' ($Version)."