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 new file mode 100644 index 000000000..573812be0 --- /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._-]*|copilot/samples-feed-hydration-bootstrap)$') + { + 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 ` + 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._-]*|copilot/samples-feed-hydration-bootstrap)$') + { + 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 ` + 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..7bbc43eb3 --- /dev/null +++ b/eng/README.md @@ -0,0 +1,35 @@ +# 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. 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 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. + +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..bf37a53ba --- /dev/null +++ b/eng/SetSamplesFeedUpstream.ps1 @@ -0,0 +1,70 @@ +[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 { + !$_.PSObject.Properties["deletedDate"] -or !$_.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)."