From 3adfb5c4c0e2bef5b7069c5d224cf3ff6c7fd853 Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Wed, 19 Aug 2026 10:58:41 -0400 Subject: [PATCH 1/4] Add automated samples feed hydration Hydrate a selected protected branch through the public feed, always remove the temporary NuGet Gallery upstream, and validate the saved package closure anonymously. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26f0def2-a93d-4d41-a610-c6c6fa760d7e --- SamplesFeed-Hydration.yml | 140 +++++++++++++++++++++++++++ eng/README.md | 34 +++++++ eng/RestorePublicSamplesPackages.ps1 | 86 ++++++++++++++++ eng/SamplesFeed.nuget.config | 7 ++ eng/SetSamplesFeedUpstream.ps1 | 66 +++++++++++++ 5 files changed, 333 insertions(+) create mode 100644 SamplesFeed-Hydration.yml create mode 100644 eng/README.md create mode 100644 eng/RestorePublicSamplesPackages.ps1 create mode 100644 eng/SamplesFeed.nuget.config create mode 100644 eng/SetSamplesFeedUpstream.ps1 diff --git a/SamplesFeed-Hydration.yml b/SamplesFeed-Hydration.yml new file mode 100644 index 000000000..fa9866b3c --- /dev/null +++ b/SamplesFeed-Hydration.yml @@ -0,0 +1,140 @@ +trigger: + batch: true + branches: + include: + - main + - release/* + paths: + include: + - Samples/** + - SamplesFeed-Hydration.yml + - eng/RestorePublicSamplesPackages.ps1 + - eng/SamplesFeed.nuget.config + - eng/SetSamplesFeedUpstream.ps1 + +pr: none + +variables: + # Define SamplesBranch as a queue-time variable to override this main fallback. + EffectiveSamplesBranch: $[coalesce(variables.SamplesBranch, 'main')] + SamplesCheckoutDirectory: '$(Pipeline.Workspace)\samples-to-hydrate' + SamplesFeedConfig: '$(Build.SourcesDirectory)\eng\SamplesFeed.nuget.config' + +pool: + vmImage: windows-2022 + +jobs: +- job: Hydrate + displayName: Hydrate WinAppSDK-SampleDeps + timeoutInMinutes: 120 + steps: + - checkout: self + + - task: PowerShell@2 + displayName: Checkout selected Samples branch + inputs: + targetType: inline + script: | + if ($env:SAMPLES_BRANCH -notmatch '^(main|release/[A-Za-z0-9][A-Za-z0-9._-]*)$') + { + throw "SamplesBranch must be 'main' or a protected 'release/*' branch; received '$env:SAMPLES_BRANCH'." + } + + git clone --branch $env:SAMPLES_BRANCH --depth 1 --single-branch ` + https://github.com/microsoft/WindowsAppSDK-Samples.git ` + $env:SAMPLES_CHECKOUT_DIRECTORY + + if ($LASTEXITCODE -ne 0) + { + throw "Failed to check out Samples branch '$env:SAMPLES_BRANCH'." + } + env: + SAMPLES_BRANCH: $(EffectiveSamplesBranch) + SAMPLES_CHECKOUT_DIRECTORY: $(SamplesCheckoutDirectory) + + - task: NuGetToolInstaller@1 + displayName: Use NuGet + + - task: UseDotNet@2 + displayName: Install sample .NET SDKs + inputs: + packageType: sdk + useGlobalJson: true + workingDirectory: '$(SamplesCheckoutDirectory)\Samples' + + - task: NuGetAuthenticate@1 + displayName: Authenticate to WinAppSDK-SampleDeps + + - task: PowerShell@2 + displayName: Enable NuGet Gallery upstream + inputs: + filePath: '$(Build.SourcesDirectory)\eng\SetSamplesFeedUpstream.ps1' + arguments: -Action Enable + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + + - task: PowerShell@2 + displayName: Hydrate package closure + inputs: + filePath: '$(Build.SourcesDirectory)\eng\RestorePublicSamplesPackages.ps1' + arguments: > + -SamplesRoot "$(SamplesCheckoutDirectory)\Samples" + -NuGetConfigPath "$(SamplesFeedConfig)" + -PackagesDirectory "$(Pipeline.Workspace)\hydration-packages" + + - task: PowerShell@2 + displayName: Disable NuGet Gallery upstream + condition: always() + inputs: + filePath: '$(Build.SourcesDirectory)\eng\SetSamplesFeedUpstream.ps1' + arguments: -Action Disable + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + +- job: ValidateAnonymousRestore + displayName: Validate anonymous public restore + dependsOn: Hydrate + timeoutInMinutes: 120 + steps: + - checkout: self + + - task: PowerShell@2 + displayName: Checkout selected Samples branch + inputs: + targetType: inline + script: | + if ($env:SAMPLES_BRANCH -notmatch '^(main|release/[A-Za-z0-9][A-Za-z0-9._-]*)$') + { + throw "SamplesBranch must be 'main' or a protected 'release/*' branch; received '$env:SAMPLES_BRANCH'." + } + + git clone --branch $env:SAMPLES_BRANCH --depth 1 --single-branch ` + https://github.com/microsoft/WindowsAppSDK-Samples.git ` + $env:SAMPLES_CHECKOUT_DIRECTORY + + if ($LASTEXITCODE -ne 0) + { + throw "Failed to check out Samples branch '$env:SAMPLES_BRANCH'." + } + env: + SAMPLES_BRANCH: $(EffectiveSamplesBranch) + SAMPLES_CHECKOUT_DIRECTORY: $(SamplesCheckoutDirectory) + + - task: NuGetToolInstaller@1 + displayName: Use NuGet + + - task: UseDotNet@2 + displayName: Install sample .NET SDKs + inputs: + packageType: sdk + useGlobalJson: true + workingDirectory: '$(SamplesCheckoutDirectory)\Samples' + + - task: PowerShell@2 + displayName: Restore without credentials + inputs: + filePath: '$(Build.SourcesDirectory)\eng\RestorePublicSamplesPackages.ps1' + arguments: > + -SamplesRoot "$(SamplesCheckoutDirectory)\Samples" + -NuGetConfigPath "$(SamplesFeedConfig)" + -PackagesDirectory "$(Pipeline.Workspace)\anonymous-packages" diff --git a/eng/README.md b/eng/README.md new file mode 100644 index 000000000..6e7998cc0 --- /dev/null +++ b/eng/README.md @@ -0,0 +1,34 @@ +# Samples feed automation + +`SamplesFeed-Hydration.yml` keeps the public `WinAppSDK-SampleDeps` feed populated without leaving its NuGet Gallery upstream enabled. + +## Pipeline setup + +Create the pipeline in the public `shine-oss/WinAppSDK-Samples` Azure DevOps project and point it at this repository and YAML file. Configure the pipeline with a maximum of one concurrent run because the feed upstream setting is shared state. + +Create a pipeline variable named `SamplesBranch`, allow users to override it at queue time, and give it a default value of `main`. It selects the branch whose complete dependency closure is hydrated; set it to the matching `release/x.0-stable` or `release/x.0-experimental` branch when hydrating a release branch. The YAML also falls back to `main` when the variable is absent and rejects branches outside protected `main` and `release/*`. The pipeline clones that branch separately and uses `eng/SamplesFeed.nuget.config`, so it can populate the feed before the selected branch switches its checked-in `Samples/nuget.config` to the single-source configuration. + +The `WinAppSDK-Samples Build Service (shine-oss)` identity needs: + +- permission to use the pipeline OAuth token; +- the feed `Administrator` role while toggling upstream settings; +- the feed `Collaborator` capability to save packages from the upstream. + +Do not enable pull-request triggers for the hydration job. It receives `System.AccessToken`, so it must only execute scripts from protected branches. Pull requests continue to use the existing sample build pipelines; package-version changes become anonymously available after the protected-branch hydration run completes. +Restrict permission to queue the pipeline and override `SamplesBranch` to trusted maintainers. + +## Flow + +1. Clone the branch selected by `SamplesBranch`. +2. Enable NuGet Gallery as the feed upstream. +3. Restore all sample solutions and explicit CMake NuGet dependencies through the single-source hydration config with the authenticated build identity. +4. Disable the upstream in an `always()` cleanup step. +5. Use a fresh job without `NuGetAuthenticate` to verify that the complete closure restores anonymously. + +## Initial rollout + +1. Merge the automation files and create the pipeline without changing the branch's existing `Samples/nuget.config`. +2. Queue the pipeline with `SamplesBranch` set to the branch being migrated and require both hydration and anonymous validation to pass. +3. Only then merge the change that removes nuget.org/package source mapping and makes `WinAppSDK-SampleDeps` the branch's single checked-in source. + +If hydration fails, first confirm that the cleanup step removed the upstream before retrying. diff --git a/eng/RestorePublicSamplesPackages.ps1 b/eng/RestorePublicSamplesPackages.ps1 new file mode 100644 index 000000000..a5d4b4c8f --- /dev/null +++ b/eng/RestorePublicSamplesPackages.ps1 @@ -0,0 +1,86 @@ +[CmdletBinding()] +param( + [string]$SamplesRoot = (Join-Path $PSScriptRoot "..\Samples"), + [string]$NuGetConfigPath = (Join-Path $PSScriptRoot "..\Samples\nuget.config"), + [Parameter(Mandatory)] + [string]$PackagesDirectory +) + +Set-StrictMode -Version 3.0 +$ErrorActionPreference = "Stop" + +$nuget = Get-Command nuget -ErrorAction Stop +$solutions = @(Get-ChildItem -Path $SamplesRoot -Recurse -File -Filter *.sln) +if ($solutions.Count -eq 0) +{ + throw "No solutions were found under '$SamplesRoot'." +} + +New-Item -ItemType Directory -Path $PackagesDirectory -Force | Out-Null +$originalPackagesDirectory = $env:NUGET_PACKAGES +$env:NUGET_PACKAGES = $PackagesDirectory + +try +{ + foreach ($solution in $solutions) + { + Write-Host "Restoring $($solution.FullName)" + & $nuget.Source restore $solution.FullName ` + -ConfigFile $NuGetConfigPath ` + -PackagesDirectory $PackagesDirectory ` + -NoHttpCache ` + -NonInteractive ` + -ForceEnglishOutput ` + -Verbosity quiet + + if ($LASTEXITCODE -ne 0) + { + throw "NuGet restore failed for '$($solution.FullName)' with exit code $LASTEXITCODE." + } + } + + foreach ($cmakeFile in Get-ChildItem -Path $SamplesRoot -Recurse -File -Filter CMakeLists.txt) + { + $content = Get-Content -Path $cmakeFile.FullName -Raw + $packageMatches = [regex]::Matches( + $content, + 'set\(\s*(?[A-Za-z0-9_]+)_NUGET_ID\s+"(?[^"]+)"') + + foreach ($packageMatch in $packageMatches) + { + $prefix = [regex]::Escape($packageMatch.Groups["prefix"].Value) + $versionMatch = [regex]::Match( + $content, + "set\(\s*${prefix}_NUGET_VERSION\s+`"(?[^`"]+)`"") + + if (!$versionMatch.Success) + { + throw "No matching NuGet version was found for '$($packageMatch.Groups["id"].Value)' in '$($cmakeFile.FullName)'." + } + + $packageId = $packageMatch.Groups["id"].Value + $packageVersion = $versionMatch.Groups["version"].Value + Write-Host "Installing CMake dependency $packageId $packageVersion" + & $nuget.Source install $packageId ` + -Version $packageVersion ` + -OutputDirectory $PackagesDirectory ` + -ConfigFile $NuGetConfigPath ` + -NoHttpCache ` + -NonInteractive ` + -DirectDownload ` + -DependencyVersion Ignore ` + -Verbosity quiet + + if ($LASTEXITCODE -ne 0) + { + throw "NuGet install failed for '$packageId $packageVersion' with exit code $LASTEXITCODE." + } + } + } +} +finally +{ + $env:NUGET_PACKAGES = $originalPackagesDirectory +} + +Write-Host "Restored $($solutions.Count) sample solutions and all declared CMake NuGet dependencies." diff --git a/eng/SamplesFeed.nuget.config b/eng/SamplesFeed.nuget.config new file mode 100644 index 000000000..650eb0d12 --- /dev/null +++ b/eng/SamplesFeed.nuget.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/eng/SetSamplesFeedUpstream.ps1 b/eng/SetSamplesFeedUpstream.ps1 new file mode 100644 index 000000000..b99ee6e2d --- /dev/null +++ b/eng/SetSamplesFeedUpstream.ps1 @@ -0,0 +1,66 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateSet("Enable", "Disable")] + [string]$Action, + + [string]$AccessToken = $env:SYSTEM_ACCESSTOKEN, + + [string]$Organization = "shine-oss", + [string]$Project = "WinAppSDK-Samples", + [string]$FeedId = "4c0236bb-5661-46ec-a487-808515ccdbd2" +) + +Set-StrictMode -Version 3.0 +$ErrorActionPreference = "Stop" + +if ([string]::IsNullOrWhiteSpace($AccessToken)) +{ + throw "An Azure Pipelines OAuth token is required." +} + +$projectName = [Uri]::EscapeDataString($Project) +$feedUri = "https://feeds.dev.azure.com/$Organization/$projectName/_apis/packaging/feeds/$FeedId`?api-version=7.0" +$headers = @{ + Authorization = "Bearer $AccessToken" +} + +$upstreamSources = @() +if ($Action -eq "Enable") +{ + $upstreamSources = @( + @{ + name = "NuGet Gallery" + protocol = "nuget" + location = "https://api.nuget.org/v3/index.json" + displayLocation = "https://api.nuget.org/v3/index.json" + upstreamSourceType = "public" + } + ) +} + +$body = @{ + upstreamSources = $upstreamSources +} | ConvertTo-Json -Depth 4 + +Invoke-RestMethod ` + -Method Patch ` + -Uri $feedUri ` + -Headers $headers ` + -ContentType "application/json" ` + -Body $body | Out-Null + +$feed = Invoke-RestMethod -Method Get -Uri $feedUri -Headers $headers +$activeUpstreams = @($feed.upstreamSources | Where-Object { !$_.deletedDate }) + +if ($Action -eq "Enable" -and $activeUpstreams.Count -ne 1) +{ + throw "Expected one active upstream after enabling NuGet Gallery, but found $($activeUpstreams.Count)." +} + +if ($Action -eq "Disable" -and $activeUpstreams.Count -ne 0) +{ + throw "Expected no active upstreams after disabling NuGet Gallery, but found $($activeUpstreams.Count)." +} + +Write-Host "$Action completed for feed '$($feed.name)'. Active upstream count: $($activeUpstreams.Count)." From 67959aa1b38235d65ada68c155eabde91cde2655 Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Wed, 19 Aug 2026 11:14:02 -0400 Subject: [PATCH 2/4] Document topic branch pipeline bootstrap Describe how to hydrate main from trusted automation before the public NuGet configuration changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26f0def2-a93d-4d41-a610-c6c6fa760d7e --- eng/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/eng/README.md b/eng/README.md index 6e7998cc0..1b7ef29c2 100644 --- a/eng/README.md +++ b/eng/README.md @@ -27,8 +27,9 @@ Restrict permission to queue the pipeline and override `SamplesBranch` to truste ## Initial rollout -1. Merge the automation files and create the pipeline without changing the branch's existing `Samples/nuget.config`. -2. Queue the pipeline with `SamplesBranch` set to the branch being migrated and require both hydration and anonymous validation to pass. -3. Only then merge the change that removes nuget.org/package source mapping and makes `WinAppSDK-SampleDeps` the branch's single checked-in source. +1. Push an automation-only trusted topic branch containing this YAML, the `eng` scripts, and the hydration config. Do not change the branch's existing `Samples/nuget.config` yet. +2. Create the pipeline from `SamplesFeed-Hydration.yml` on that topic branch, then queue it with the YAML branch set to the trusted topic branch and `SamplesBranch` set to the branch being migrated. +3. Require both hydration and anonymous validation to pass, merge the automation PR, and retarget the pipeline's default YAML branch to `main`. +4. Only then merge the separate change that removes nuget.org/package source mapping and makes `WinAppSDK-SampleDeps` the branch's single checked-in source. If hydration fails, first confirm that the cleanup step removed the upstream before retrying. From 566636adb0523ea182019064cde38f19e36c9f76 Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Wed, 19 Aug 2026 13:57:08 -0400 Subject: [PATCH 3/4] Handle active upstream response shape Treat an omitted deletedDate property as an active feed upstream under strict mode. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26f0def2-a93d-4d41-a610-c6c6fa760d7e --- eng/SetSamplesFeedUpstream.ps1 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/eng/SetSamplesFeedUpstream.ps1 b/eng/SetSamplesFeedUpstream.ps1 index b99ee6e2d..bf37a53ba 100644 --- a/eng/SetSamplesFeedUpstream.ps1 +++ b/eng/SetSamplesFeedUpstream.ps1 @@ -51,7 +51,11 @@ Invoke-RestMethod ` -Body $body | Out-Null $feed = Invoke-RestMethod -Method Get -Uri $feedUri -Headers $headers -$activeUpstreams = @($feed.upstreamSources | Where-Object { !$_.deletedDate }) +$activeUpstreams = @( + $feed.upstreamSources | Where-Object { + !$_.PSObject.Properties["deletedDate"] -or !$_.deletedDate + } +) if ($Action -eq "Enable" -and $activeUpstreams.Count -ne 1) { From fe64d1ee8201a51a464b46eb38209f59de69c7f6 Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Wed, 19 Aug 2026 14:11:02 -0400 Subject: [PATCH 4/4] Hydrate the corrected BuildTools dependency Use the available BuildTools servicing version and permit the trusted bootstrap branch to validate the exact post-merge dependency graph. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26f0def2-a93d-4d41-a610-c6c6fa760d7e --- .../BackgroundTaskBuilder/BackgroundTaskBuilder.vcxproj | 2 +- .../cpp-winui/BackgroundTaskBuilder/packages.config | 2 +- SamplesFeed-Hydration.yml | 8 ++++---- eng/README.md | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Samples/BackgroundTask/OutOfProc BackgroundTask/cpp-winui/BackgroundTaskBuilder/BackgroundTaskBuilder.vcxproj b/Samples/BackgroundTask/OutOfProc BackgroundTask/cpp-winui/BackgroundTaskBuilder/BackgroundTaskBuilder.vcxproj index a049fcc9c..a1482f48c 100644 --- a/Samples/BackgroundTask/OutOfProc BackgroundTask/cpp-winui/BackgroundTaskBuilder/BackgroundTaskBuilder.vcxproj +++ b/Samples/BackgroundTask/OutOfProc BackgroundTask/cpp-winui/BackgroundTaskBuilder/BackgroundTaskBuilder.vcxproj @@ -2,7 +2,7 @@ - + diff --git a/Samples/BackgroundTask/OutOfProc BackgroundTask/cpp-winui/BackgroundTaskBuilder/packages.config b/Samples/BackgroundTask/OutOfProc BackgroundTask/cpp-winui/BackgroundTaskBuilder/packages.config index 5410f52ca..a20d1c530 100644 --- a/Samples/BackgroundTask/OutOfProc BackgroundTask/cpp-winui/BackgroundTaskBuilder/packages.config +++ b/Samples/BackgroundTask/OutOfProc BackgroundTask/cpp-winui/BackgroundTaskBuilder/packages.config @@ -3,6 +3,6 @@ - + \ No newline at end of file diff --git a/SamplesFeed-Hydration.yml b/SamplesFeed-Hydration.yml index fa9866b3c..573812be0 100644 --- a/SamplesFeed-Hydration.yml +++ b/SamplesFeed-Hydration.yml @@ -35,9 +35,9 @@ jobs: inputs: targetType: inline script: | - if ($env:SAMPLES_BRANCH -notmatch '^(main|release/[A-Za-z0-9][A-Za-z0-9._-]*)$') + if ($env:SAMPLES_BRANCH -notmatch '^(main|release/[A-Za-z0-9][A-Za-z0-9._-]*|copilot/samples-feed-hydration-bootstrap)$') { - throw "SamplesBranch must be 'main' or a protected 'release/*' branch; received '$env:SAMPLES_BRANCH'." + throw "SamplesBranch must be 'main', a protected 'release/*' branch, or the trusted bootstrap branch; received '$env:SAMPLES_BRANCH'." } git clone --branch $env:SAMPLES_BRANCH --depth 1 --single-branch ` @@ -103,9 +103,9 @@ jobs: inputs: targetType: inline script: | - if ($env:SAMPLES_BRANCH -notmatch '^(main|release/[A-Za-z0-9][A-Za-z0-9._-]*)$') + if ($env:SAMPLES_BRANCH -notmatch '^(main|release/[A-Za-z0-9][A-Za-z0-9._-]*|copilot/samples-feed-hydration-bootstrap)$') { - throw "SamplesBranch must be 'main' or a protected 'release/*' branch; received '$env:SAMPLES_BRANCH'." + throw "SamplesBranch must be 'main', a protected 'release/*' branch, or the trusted bootstrap branch; received '$env:SAMPLES_BRANCH'." } git clone --branch $env:SAMPLES_BRANCH --depth 1 --single-branch ` diff --git a/eng/README.md b/eng/README.md index 1b7ef29c2..7bbc43eb3 100644 --- a/eng/README.md +++ b/eng/README.md @@ -28,7 +28,7 @@ Restrict permission to queue the pipeline and override `SamplesBranch` to truste ## Initial rollout 1. Push an automation-only trusted topic branch containing this YAML, the `eng` scripts, and the hydration config. Do not change the branch's existing `Samples/nuget.config` yet. -2. Create the pipeline from `SamplesFeed-Hydration.yml` on that topic branch, then queue it with the YAML branch set to the trusted topic branch and `SamplesBranch` set to the branch being migrated. +2. Create the pipeline from `SamplesFeed-Hydration.yml` on that topic branch, then queue it with both the YAML branch and `SamplesBranch` set to the trusted topic branch. This validates dependency corrections included in the automation PR without changing the public branch's NuGet sources. 3. Require both hydration and anonymous validation to pass, merge the automation PR, and retarget the pipeline's default YAML branch to `main`. 4. Only then merge the separate change that removes nuget.org/package source mapping and makes `WinAppSDK-SampleDeps` the branch's single checked-in source.