From 558e633c59aa8515fe7533b5b2a964c3b6bf4a85 Mon Sep 17 00:00:00 2001 From: Boliang Zhang <122517415+LegendaryBlair@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:45:11 +0800 Subject: [PATCH] Add preview release versioning and update channel support (#49414) ## Summary - Publish scheduled `main` builds as GitHub prereleases while keeping manual `main` runs as preview validation builds. - Add an opt-in Settings switch for prerelease update checks; stable updates remain the default. - Use one MSI-safe version across bundles, MSI packages, binaries, symbols, and package manifests. - Prevent preview releases from triggering Microsoft Store, WinGet, or public-symbol publication. - Label preview builds explicitly in Settings, update notifications, and What's New. ## Build intent | Source | Trigger | Intent | | --- | --- | --- | | `main` | Scheduled | Publish a preview release | | `main` | Manual | Validate a preview build without publishing | | `stable` | Manual | Produce a stable release | | Other branches | Any supported trigger | Produce a private validation build | ## MSI-safe release versioning Windows Installer compares only `major.minor.build` and ignores the fourth version component. Preview and stable release builds therefore use: ```text major.minor.YDDDB.0 ``` - `Y`: zero-based number of calendar years since `ReleaseTrainEpoch`. - `DDD`: three-position calendar day of year. - `B`: daily release sequence `1-9`. - The fourth component is always `0`. With `ReleaseTrainVersion=0.100` and `ReleaseTrainEpoch=2026-01-01`: ```text 0.100.2111.0 = July 30, 2026, release build 1 0.100.3659.0 = December 31, 2026, release build 9 0.100.10011.0 = January 1, 2027, release build 1 ``` The allocator formats `DDD` as exactly three digits before converting the MSI component to its numeric representation. Leading zeros may not be displayed because Windows version components are numeric; decoding remains positional: ```text B = component % 10 DDD = (component / 10) % 1000 Y = component / 10000 ``` `ReleaseTrainVersion` and `ReleaseTrainEpoch` are checked in under `src/Version.props`. The epoch remains January 1 of the active epoch year and advances on the first release-train minor change in a new year. ## Daily release counter Azure DevOps persists the daily sequence server-side using a counter keyed as `release-YYYYMMDD`. - `main` and `stable` share the same daily counter. - Other branches do not evaluate or consume the release counter. - Failed or canceled `main`/`stable` runs may leave gaps. - The build fails when the daily sequence exceeds `9`. - The counter date and encoded `YDDD` date both use `pipeline.startTime`. Private branches retain independent `0.0..0` validation versions. ## Update behavior - Stable users continue to query GitHub's stable latest-release path. - Users who explicitly enable preview updates can select newer GitHub prereleases. - Preview releases and notifications are labeled as PowerToys Preview. - What's New separates preview entries from stable release history and hides previews by default. ## Validation - 17 Pester tests cover `main`, `stable`, private branches, year rollover, epoch reset, monotonicity, override validation, sequence limits, and date alignment. - Version propagation verified `0.100.2111.0` in `Version.props` and all affected AppX/MSIX manifests. - Azure DevOps pipeline dry-runs succeeded for both `refs/heads/main` and `refs/heads/stable`. - The affected native version project builds successfully. - PR CI is green for x64, ARM64, Command Palette SDK, dependency review, telemetry detection, and CLA. ## Remaining end-to-end checks - Install two locally or officially produced installers with consecutive MSI-visible `YDDDB` versions and verify the upgrade preserves binaries, package registrations, hardlinks, and shell integrations. - On the first natural post-merge `main` or `stable` run, verify the production counter value and resolved version in the release logs. ## Local GPO verification Validated locally with the signed `v0.100.2171` build from Azure DevOps build [153961073](https://microsoft.visualstudio.com/Dart/_build/results?buildId=153961073). These checks cover the administrative-template integration and Settings behavior. ### Policy enabled: preview updates are disabled With `PreviewUpdatesDisabled=1`, **Include prerelease updates** is forced off and locked, and Settings displays the managed-by-your-organization notice. ![PowerToys Settings with preview updates disabled by policy](https://raw.githubusercontent.com/LegendaryBlair/PowerToys/df808630b04e65ba437081aff9401c4efd58e67f/.github/pr-assets/49414/gpo-policy-enforced.png) ### Policy removed: the user preference is preserved After removing `PreviewUpdatesDisabled` and restarting PowerToys, the previously selected preview-update preference is restored and editable. The policy suppresses the preference without overwriting it. ![PowerToys Settings with the preview-update preference restored](https://raw.githubusercontent.com/LegendaryBlair/PowerToys/df808630b04e65ba437081aff9401c4efd58e67f/.github/pr-assets/49414/gpo-preference-restored.png) ### Group Policy Editor After importing the updated ADMX/ADML templates, **Disable preview build updates** appears under **Microsoft PowerToys > Installer and Updates**. The policy dialog documents that **Enabled** blocks preview updates, while **Disabled** or **Not Configured** leaves the choice available to the user. ![Disable preview build updates in Local Group Policy Editor](https://raw.githubusercontent.com/LegendaryBlair/PowerToys/e9e5c12f4480ef895d263460a59982246b7654dc/.github/pr-assets/49414/group-policy-editor-policy-dialog.png) --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ad8b7909-0472-4464-bdee-deaeca726f94 Copilot-Session: 8e04a72e-3b0f-4ac4-8156-d04ea9b8bb85 --- .github/actions/spell-check/allow/code.txt | 11 + .../scripts/create-github-draft-release.ps1 | 213 +++++++++++ .github/workflows/msstore-submissions.yml | 1 + .pipelines/resolveBuildMetadata.ps1 | 339 ++++++++++++++++++ .../tests/resolveBuildMetadata.Tests.ps1 | 268 ++++++++++++++ .pipelines/v2/release.yml | 31 +- .pipelines/v2/templates/job-build-project.yml | 11 +- ...sh-symbols-using-symbolrequestprod-api.yml | 33 +- .pipelines/versionSetting.ps1 | 92 +++-- Directory.Build.props | 6 +- installer/PowerToysSetupVNext/Core.wxs | 6 +- .../PowerToysSetupVNext/DscResources.wxs | 4 +- .../PowerToysInstallerVNext.wixproj | 9 +- src/Update/PowerToys.Update.cpp | 3 +- src/Version.props | 6 + src/common/GPOWrapper/GPOWrapper.cpp | 4 + src/common/GPOWrapper/GPOWrapper.h | 1 + src/common/GPOWrapper/GPOWrapper.idl | 1 + .../UnitTestsVersionHelper.cpp | 63 ++++ src/common/interop/CommonManaged.cpp | 10 + src/common/interop/CommonManaged.h | 2 + src/common/interop/CommonManaged.idl | 2 + src/common/updating/installer.cpp | 4 +- src/common/updating/updateState.cpp | 4 +- src/common/updating/updateState.h | 1 + src/common/updating/updating.cpp | 14 +- src/common/updating/updating.h | 5 +- src/common/utils/gpo.h | 6 + src/common/utils/package.h | 2 +- src/common/version/helper.cpp | 61 +++- src/common/version/helper.h | 3 +- src/common/version/version.h | 39 +- src/common/version/version.vcxproj | 12 + src/gpo/assets/PowerToys.admx | 15 +- src/gpo/assets/en-US/PowerToys.adml | 9 +- src/runner/Resources.resx | 3 + src/runner/UpdateUtils.cpp | 49 ++- src/runner/general_settings.cpp | 6 +- src/runner/general_settings.h | 1 + src/runner/settings_window.cpp | 1 + src/runner/trace.cpp | 1 + .../Settings.UI.Library/GeneralSettings.cs | 4 + .../Settings.UI.Library/UpdatingSettings.cs | 3 + .../ScoobeReleaseTests.cs | 71 ++++ .../ViewModelTests/General.cs | 62 ++++ .../Helpers/PowerToysReleaseInfo.cs | 3 + .../Settings.UI/PowerToys.Settings.csproj | 4 + .../OOBE/Views/ScoobeReleaseGroupViewModel.cs | 8 +- .../OOBE/Views/ScoobeReleaseNotesPage.xaml.cs | 6 + .../SettingsXAML/ScoobeWindow.xaml.cs | 42 ++- .../SettingsXAML/Views/GeneralPage.xaml | 60 +++- .../Settings.UI/Strings/en-us/Resources.resw | 27 ++ .../ViewModels/GeneralViewModel.cs | 66 +++- tools/build/build-installer.ps1 | 4 +- tools/build/versionSetting.ps1 | 78 +++- 55 files changed, 1672 insertions(+), 118 deletions(-) create mode 100644 .github/skills/release-note-generation/scripts/create-github-draft-release.ps1 create mode 100644 .pipelines/resolveBuildMetadata.ps1 create mode 100644 .pipelines/tests/resolveBuildMetadata.Tests.ps1 create mode 100644 src/settings-ui/Settings.UI.UnitTests/ScoobeReleaseTests.cs diff --git a/.github/actions/spell-check/allow/code.txt b/.github/actions/spell-check/allow/code.txt index 7e895c4df2..c74af48978 100644 --- a/.github/actions/spell-check/allow/code.txt +++ b/.github/actions/spell-check/allow/code.txt @@ -401,6 +401,17 @@ HHH riday YYY +# Release versioning identifiers and format fragments +DAILYVERSIONSEQUENCE +DDNNN +Mdd +SOURCEBRANCH +SOURCEVERSION +VERSIONDATE +YDDD +YDDDB +YYMM + # Unicode precomposed diff --git a/.github/skills/release-note-generation/scripts/create-github-draft-release.ps1 b/.github/skills/release-note-generation/scripts/create-github-draft-release.ps1 new file mode 100644 index 0000000000..bdf2dd3eee --- /dev/null +++ b/.github/skills/release-note-generation/scripts/create-github-draft-release.ps1 @@ -0,0 +1,213 @@ +<# +.SYNOPSIS + Creates or updates a GitHub draft release from a prepared PowerToys release folder. + +.DESCRIPTION + Validates a prepared release folder, finds the four installers, two symbol + zips, GPO zip, and release notes, then uses GitHub CLI (`gh`) to create or + update a draft release and upload the assets. Asset filenames use the + numeric product version; -TagName may include channel suffixes such as + v0.100.2607.08001-preview. +#> +param( + [Parameter(Mandatory = $true)] + [string]$ReleaseFolder, + + [string]$GitHubRepo = "microsoft/PowerToys", + + [string]$TagName, + + [string]$Target, + + [string]$Title, + + [string]$NotesFile, + + [switch]$Prerelease, + + [switch]$LatestFalse, + + [switch]$Clobber, + + [switch]$DryRun +) + +$ErrorActionPreference = "Stop" + +function Resolve-ExistingPath { + param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Description) + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue + if (-not $resolved) { + throw "$Description not found: $Path" + } + + return $resolved.ProviderPath +} + +function Invoke-Gh { + param([Parameter(Mandatory)][string[]]$Arguments, [switch]$AllowFailure) + if ($DryRun) { + Write-Host "gh $($Arguments -join ' ')" -ForegroundColor DarkGray + return $null + } + + $output = & gh @Arguments 2>&1 + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0 -and -not $AllowFailure) { + throw "gh $($Arguments -join ' ') failed with exit code $exitCode.`n$output" + } + + if ($exitCode -ne 0) { + return $null + } + + return $output +} + +function Get-ReleaseNotesFile { + param([Parameter(Mandatory)][string]$Folder, [Parameter(Mandatory)][string]$Version, [string]$ExplicitNotesFile) + if ($ExplicitNotesFile) { + return Resolve-ExistingPath -Path $ExplicitNotesFile -Description "Release notes file" + } + + $markdownFiles = @(Get-ChildItem -LiteralPath $Folder -File -Filter "*.md" | Where-Object { + $_.Name -notmatch '^(hashes|sha256|checksums)\.md$' + }) + + foreach ($name in @("v$Version-release-notes.md", "$Version-release-notes.md", "release-notes.md", "ReleaseNotes.md", "notes.md")) { + $match = $markdownFiles | Where-Object { $_.Name -ieq $name } | Select-Object -First 1 + if ($match) { + return $match.FullName + } + } + + if ($markdownFiles.Count -eq 1) { + return $markdownFiles[0].FullName + } + + throw "Could not infer release notes file. Pass -NotesFile explicitly." +} + +function Test-ReleaseNotesInstallerLinks { + param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Tag, [Parameter(Mandatory)][string]$Version) + $content = Get-Content -LiteralPath $Path -Raw + $expectedLinks = @{ + "ptUserX64" = "https://github.com/$GitHubRepo/releases/download/$Tag/PowerToysUserSetup-$Version-x64.exe" + "ptUserArm64" = "https://github.com/$GitHubRepo/releases/download/$Tag/PowerToysUserSetup-$Version-arm64.exe" + "ptMachineX64" = "https://github.com/$GitHubRepo/releases/download/$Tag/PowerToysSetup-$Version-x64.exe" + "ptMachineArm64" = "https://github.com/$GitHubRepo/releases/download/$Tag/PowerToysSetup-$Version-arm64.exe" + } + + foreach ($link in $expectedLinks.GetEnumerator()) { + if ($content -notmatch "(?im)^\[$([regex]::Escape($link.Key))\]:\s*$([regex]::Escape($link.Value))\s*$") { + throw "Release notes are missing installer link reference [$($link.Key)]: $($link.Value)" + } + } +} + +if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { + throw "GitHub CLI (`gh`) was not found in PATH." +} + +Invoke-Gh -Arguments @("auth", "status") | Out-Null + +$releaseFolderPath = Resolve-ExistingPath -Path $ReleaseFolder -Description "Release folder" +if (-not (Test-Path -LiteralPath $releaseFolderPath -PathType Container)) { + throw "ReleaseFolder must be a directory: $releaseFolderPath" +} + +$allFiles = @(Get-ChildItem -LiteralPath $releaseFolderPath -File) +$installerMatches = @() +foreach ($file in $allFiles) { + if ($file.Name -match '^PowerToys(?User)?Setup-(?.+)-(?x64|arm64)\.exe$') { + $installerMatches += [pscustomobject]@{ File = $file; Version = $Matches.Version; Arch = $Matches.Arch; IsUser = -not [string]::IsNullOrEmpty($Matches.User) } + } +} + +if ($installerMatches.Count -ne 4) { + throw "Expected exactly four PowerToys installer EXEs, found $($installerMatches.Count)." +} + +$versions = @($installerMatches | Select-Object -ExpandProperty Version -Unique) +if ($versions.Count -ne 1) { + throw "Installer versions do not match: $($versions -join ', ')" +} + +$numericVersion = $versions[0] +if (-not $TagName) { + $TagName = "v$numericVersion" +} +if (-not $Title) { + $displayVersion = $TagName.TrimStart('v', 'V') -replace '-preview$', '' + $Title = if ($Prerelease) { "PowerToys v$displayVersion Preview" } else { "Release v$numericVersion" } +} + +$filesByName = @{} +foreach ($file in $allFiles) { + $filesByName[$file.Name] = $file +} + +$expectedAssets = @( + "PowerToysSetup-$numericVersion-x64.exe", + "PowerToysSetup-$numericVersion-arm64.exe", + "PowerToysUserSetup-$numericVersion-x64.exe", + "PowerToysUserSetup-$numericVersion-arm64.exe", + "symbols-x64.zip", + "symbols-arm64.zip", + "GroupPolicyObjectFiles-$numericVersion.zip" +) + +$assets = New-Object System.Collections.Generic.List[string] +foreach ($name in $expectedAssets) { + if (-not $filesByName.ContainsKey($name)) { + throw "Required release asset is missing: $name" + } + $assets.Add($filesByName[$name].FullName) +} + +$notesFilePath = Get-ReleaseNotesFile -Folder $releaseFolderPath -Version $numericVersion -ExplicitNotesFile $NotesFile +Test-ReleaseNotesInstallerLinks -Path $notesFilePath -Tag $TagName -Version $numericVersion + +Write-Host "Release folder: $releaseFolderPath" +Write-Host "Repository: $GitHubRepo" +Write-Host "Numeric version:$numericVersion" +Write-Host "Tag: $TagName" +Write-Host "Title: $Title" +Write-Host "Notes: $notesFilePath" +if ($Target) { + Write-Host "Target: $Target" +} else { + Write-Warning "No -Target was provided. If the tag does not already exist, gh will create it from the repository default branch." +} + +$releaseJson = Invoke-Gh -Arguments @("release", "view", $TagName, "--repo", $GitHubRepo, "--json", "isDraft,url") -AllowFailure +$releaseExists = -not [string]::IsNullOrWhiteSpace(($releaseJson | Out-String)) + +if ($releaseExists) { + $release = ($releaseJson | Out-String) | ConvertFrom-Json + if (-not $release.isDraft) { + throw "Release $TagName already exists and is not a draft: $($release.url)" + } + + $editArgs = @("release", "edit", $TagName, "--repo", $GitHubRepo, "--draft", "--title", $Title, "--notes-file", $notesFilePath) + if ($Target) { $editArgs += @("--target", $Target) } + if ($Prerelease) { $editArgs += "--prerelease" } + Invoke-Gh -Arguments $editArgs | Out-Null + + $uploadArgs = @("release", "upload", $TagName) + $assets + @("--repo", $GitHubRepo) + if ($Clobber) { $uploadArgs += "--clobber" } + Invoke-Gh -Arguments $uploadArgs | Out-Null +} else { + $createArgs = @("release", "create", $TagName) + $assets + @("--repo", $GitHubRepo, "--draft", "--title", $Title, "--notes-file", $notesFilePath) + if ($Target) { $createArgs += @("--target", $Target) } + if ($Prerelease) { $createArgs += "--prerelease" } + if ($Prerelease -or $LatestFalse) { $createArgs += "--latest=false" } + Invoke-Gh -Arguments $createArgs | Out-Null +} + +if ($DryRun) { + Write-Host "Dry run complete. No GitHub release was created or modified." -ForegroundColor Yellow +} else { + $result = Invoke-Gh -Arguments @("release", "view", $TagName, "--repo", $GitHubRepo, "--json", "url", "--jq", ".url") + Write-Host "Draft release ready: $result" -ForegroundColor Green +} diff --git a/.github/workflows/msstore-submissions.yml b/.github/workflows/msstore-submissions.yml index 24826630b9..e1924f908d 100644 --- a/.github/workflows/msstore-submissions.yml +++ b/.github/workflows/msstore-submissions.yml @@ -12,6 +12,7 @@ jobs: microsoft_store: name: Publish Microsoft Store + if: ${{ !github.event.release.prerelease }} environment: store runs-on: ubuntu-latest steps: diff --git a/.pipelines/resolveBuildMetadata.ps1 b/.pipelines/resolveBuildMetadata.ps1 new file mode 100644 index 0000000000..8018d6403d --- /dev/null +++ b/.pipelines/resolveBuildMetadata.ps1 @@ -0,0 +1,339 @@ +[CmdletBinding()] +param( + [AllowEmptyString()] + [string]$VersionOverride = "", + + [string]$SourceBranch = $env:BUILD_SOURCEBRANCH, + + [string]$BuildReason = $env:BUILD_REASON, + + [string]$BuildNumber = $env:BUILD_BUILDNUMBER, + + [AllowEmptyString()] + [string]$BuildDate = "", + + [AllowEmptyString()] + [string]$DailyVersionSequence = "", + + [string]$VersionPropsPath = (Join-Path $PSScriptRoot "..\src\Version.props") +) + +$ErrorActionPreference = "Stop" + +function Get-BuildStamp { + param([string]$PipelineBuildNumber) + + if ([string]::IsNullOrWhiteSpace($PipelineBuildNumber)) { + $now = Get-Date + return [pscustomobject]@{ + Date = $now.Date + Revision = 1 + } + } + + if ($PipelineBuildNumber -notmatch "_(?\d{4})\.(?\d{2})(?\d{3})(?:-.+)?$") { + throw "Build number '$PipelineBuildNumber' does not end with the expected _YYMM.DDNNN pattern" + } + + try { + $date = [datetime]::ParseExact( + "20$($matches["yearMonth"])$($matches["day"])", + "yyyyMMdd", + [Globalization.CultureInfo]::InvariantCulture) + } + catch { + throw "Build number '$PipelineBuildNumber' contains an invalid date" + } + + $revision = [int]::Parse($matches["revision"]) + if ($revision -lt 1 -or $revision -gt 99) { + throw "Build number '$PipelineBuildNumber' has daily revision '$revision'; canonical versions support revisions 001 through 099" + } + + return [pscustomobject]@{ + Date = $date + Revision = $revision + } +} + +function Test-VersionParts { + param([Parameter(Mandatory)][string[]]$Parts) + + foreach ($part in $Parts) { + $value = [int]::Parse($part) + if ($value -lt 0 -or $value -gt [UInt16]::MaxValue) { + throw "Version component '$value' is outside the supported Windows version range 0-65535" + } + } +} + +function Get-VersionDate { + param( + [AllowEmptyString()][string]$DateOverride, + [Parameter(Mandatory)]$BuildStamp + ) + + if ([string]::IsNullOrWhiteSpace($DateOverride)) { + return $BuildStamp.Date + } + + try { + return [datetime]::ParseExact( + $DateOverride, + "yyyyMMdd", + [Globalization.CultureInfo]::InvariantCulture) + } + catch { + throw "Build date '$DateOverride' must use the yyyyMMdd format" + } +} + +function Get-ReleaseTrainMetadata { + param([Parameter(Mandatory)][string]$Path) + + [xml]$versionProps = Get-Content -LiteralPath $Path + $releaseTrain = [string]$versionProps.Project.PropertyGroup.ReleaseTrainVersion + if ($releaseTrain -notmatch "^(?\d+)\.(?\d+)$") { + throw "ReleaseTrainVersion in '$Path' must use the major.minor format" + } + + Test-VersionParts -Parts @($matches["major"], $matches["minor"]) + if ([int]::Parse($matches["major"]) -gt 255 -or [int]::Parse($matches["minor"]) -gt 255) { + throw "ReleaseTrainVersion in '$Path' must keep major and minor within the MSI-supported range 0-255" + } + + $epochText = [string]$versionProps.Project.PropertyGroup.ReleaseTrainEpoch + try { + $epoch = [datetime]::ParseExact( + $epochText, + "yyyy-MM-dd", + [Globalization.CultureInfo]::InvariantCulture) + } + catch { + throw "ReleaseTrainEpoch in '$Path' must use the yyyy-MM-dd format" + } + + if ($epoch.Month -ne 1 -or $epoch.Day -ne 1) { + throw "ReleaseTrainEpoch in '$Path' must be January 1 of the active epoch year" + } + + return [pscustomobject]@{ + Version = $releaseTrain + Epoch = $epoch + } +} + +function Get-ReleaseVersion { + param( + [Parameter(Mandatory)][string]$ReleaseTrain, + [Parameter(Mandatory)][datetime]$Epoch, + [Parameter(Mandatory)]$BuildStamp, + [Parameter(Mandatory)][int]$DailySequence + ) + + if ($BuildStamp.Date -lt $Epoch) { + throw "Build date '$($BuildStamp.Date.ToString("yyyy-MM-dd"))' is before ReleaseTrainEpoch '$($Epoch.ToString("yyyy-MM-dd"))'" + } + + if ($DailySequence -lt 1 -or $DailySequence -gt 9) { + throw "Daily release sequence '$DailySequence' is outside the YDDDB-supported range 1-9" + } + + $yearOffset = $BuildStamp.Date.Year - $Epoch.Year + if ($yearOffset -gt 6) { + throw "Release train year offset '$yearOffset' exceeds the MSI-safe YDDDB range 0-6; advance the release train and reset ReleaseTrainEpoch" + } + + $thirdComponentText = "{0}{1:D3}{2}" -f $yearOffset, $BuildStamp.Date.DayOfYear, $DailySequence + $thirdComponent = [int]::Parse($thirdComponentText) + if ($thirdComponent -gt [UInt16]::MaxValue) { + throw "Generated version component '$thirdComponent' exceeds 65535; advance the release train and reset ReleaseTrainEpoch" + } + + return "$ReleaseTrain.$thirdComponent.0" +} + +function Get-PrivateVersion { + param( + [Parameter(Mandatory)][datetime]$Epoch, + [Parameter(Mandatory)]$BuildStamp + ) + + $extendedDay = ($BuildStamp.Date - $Epoch).Days + 1 + if ($extendedDay -lt 1) { + throw "Build date '$($BuildStamp.Date.ToString("yyyy-MM-dd"))' is before ReleaseTrainEpoch '$($Epoch.ToString("yyyy-MM-dd"))'" + } + + $thirdComponent = ($extendedDay * 100) + $BuildStamp.Revision + if ($thirdComponent -gt [UInt16]::MaxValue) { + throw "Generated private version component '$thirdComponent' exceeds 65535" + } + + return "0.0.$thirdComponent.0" +} + +function Get-ReleaseDailySequence { + param([AllowEmptyString()][string]$Sequence) + + if ([string]::IsNullOrWhiteSpace($Sequence)) { + throw "DailyVersionSequence is required for main and stable builds" + } + + if ($Sequence -notmatch "^\d+$") { + throw "Daily release sequence '$Sequence' must be numeric" + } + + return [int]::Parse($Sequence) +} + +function Get-PreviewVersion { + param( + [Parameter(Mandatory)][string]$ReleaseTrain, + [AllowEmptyString()][string]$Override, + [Parameter(Mandatory)][string]$GeneratedVersion + ) + + $inputVersion = $Override.Trim() + if ($inputVersion.EndsWith("-preview", [StringComparison]::OrdinalIgnoreCase)) { + $inputVersion = $inputVersion.Substring(0, $inputVersion.Length - "-preview".Length) + } + + if ([string]::IsNullOrWhiteSpace($inputVersion)) { + return $GeneratedVersion + } + + if ($inputVersion -match "^(?\d+)\.(?\d+)$") { + if ($inputVersion -ne $ReleaseTrain) { + throw "Preview version base '$inputVersion' does not match ReleaseTrainVersion '$ReleaseTrain'" + } + + return $GeneratedVersion + } + + if ($inputVersion -notmatch "^(?\d+)\.(?\d+)\.(?\d+)\.(?\d+)$") { + throw "Preview version override must be major.minor or major.minor.YDDDB.0, optionally followed by -preview" + } + + if ("$($matches["major"]).$($matches["minor"])" -ne $ReleaseTrain) { + throw "Preview version '$inputVersion' does not match ReleaseTrainVersion '$ReleaseTrain'" + } + + if ([int]::Parse($matches["build"]) -ne 0) { + throw "Preview version '$inputVersion' must use 0 for the fourth component" + } + + Test-VersionParts -Parts @($matches["major"], $matches["minor"], $matches["revision"], $matches["build"]) + $parts = @($matches["major"], $matches["minor"], $matches["revision"], $matches["build"]) + return ($parts | ForEach-Object { [int]::Parse($_) }) -join "." +} + +function Get-MsiSafeVersionOverride { + param( + [Parameter(Mandatory)][string]$Override, + [Parameter(Mandatory)][string]$VersionKind + ) + + $inputVersion = $Override.Trim() + if ($inputVersion -notmatch "^(?\d+)\.(?\d+)\.(?\d+)(?:\.(?\d+))?$") { + throw "$VersionKind version override must be numeric major.minor.patch or major.minor.patch.build" + } + + $parts = @($matches["major"], $matches["minor"], $matches["revision"]) + if ($matches["build"]) { + $parts += $matches["build"] + } + else { + $parts += "0" + } + + Test-VersionParts -Parts $parts + if ([int]::Parse($parts[0]) -gt 255 -or [int]::Parse($parts[1]) -gt 255) { + throw "$VersionKind version '$inputVersion' must keep major and minor within the MSI-supported range 0-255" + } + + if ([int]::Parse($parts[3]) -ne 0) { + throw "$VersionKind version '$inputVersion' must use 0 for the fourth component" + } + + return ($parts | ForEach-Object { [int]::Parse($_) }) -join "." +} + +function Get-StableVersion { + param( + [Parameter(Mandatory)][AllowEmptyString()][string]$Override, + [Parameter(Mandatory)][string]$GeneratedVersion + ) + + if ([string]::IsNullOrWhiteSpace($Override)) { + return $GeneratedVersion + } + + return Get-MsiSafeVersionOverride -Override $Override -VersionKind "Stable" +} + +$isMain = $SourceBranch -eq "refs/heads/main" +$isStable = $SourceBranch -eq "refs/heads/stable" +$isScheduled = $BuildReason -eq "Schedule" + +if ($isScheduled -and -not $isMain) { + throw "Scheduled release builds are only supported from refs/heads/main" +} + +$releaseMetadata = Get-ReleaseTrainMetadata -Path $VersionPropsPath +$releaseTrain = $releaseMetadata.Version +$buildStamp = Get-BuildStamp -PipelineBuildNumber $BuildNumber + +if ($isMain) { + if ($isScheduled -and -not [string]::IsNullOrWhiteSpace($VersionOverride)) { + throw "Scheduled main builds must use the checked-in ReleaseTrainVersion and cannot specify a version override" + } + + $intent = if ($isScheduled) { "preview-release" } else { "preview-validation" } + $channel = "preview" + $buildStamp.Date = Get-VersionDate -DateOverride $BuildDate -BuildStamp $buildStamp + $releaseDailySequence = Get-ReleaseDailySequence -Sequence $DailyVersionSequence + $generatedVersion = Get-ReleaseVersion -ReleaseTrain $releaseTrain -Epoch $releaseMetadata.Epoch -BuildStamp $buildStamp -DailySequence $releaseDailySequence + $version = Get-PreviewVersion -ReleaseTrain $releaseTrain -Override $VersionOverride -GeneratedVersion $generatedVersion + $allowPublicSymbols = $false + $shouldPublishPreview = $isScheduled +} +elseif ($isStable) { + if ($isScheduled) { + throw "Stable release builds must be queued manually" + } + + $intent = "stable-release" + $channel = "stable" + $buildStamp.Date = Get-VersionDate -DateOverride $BuildDate -BuildStamp $buildStamp + $releaseDailySequence = Get-ReleaseDailySequence -Sequence $DailyVersionSequence + $generatedVersion = Get-ReleaseVersion -ReleaseTrain $releaseTrain -Epoch $releaseMetadata.Epoch -BuildStamp $buildStamp -DailySequence $releaseDailySequence + $version = Get-StableVersion -Override $VersionOverride -GeneratedVersion $generatedVersion + $allowPublicSymbols = $true + $shouldPublishPreview = $false +} +else { + $intent = "private-validation" + $channel = "private" + $version = if ([string]::IsNullOrWhiteSpace($VersionOverride)) { + Get-PrivateVersion -Epoch $releaseMetadata.Epoch -BuildStamp $buildStamp + } + else { + Get-MsiSafeVersionOverride -Override $VersionOverride -VersionKind "Private" + } + $allowPublicSymbols = $false + $shouldPublishPreview = $false +} + +Test-VersionParts -Parts ($version -split "\.") + +Write-Host "Resolved build intent: $intent" +Write-Host "Resolved release channel: $channel" +Write-Host "Resolved version: $version" + +[pscustomobject]@{ + Intent = $intent + Channel = $channel + Version = $version + AllowPublicSymbols = $allowPublicSymbols + ShouldPublishPreview = $shouldPublishPreview +} diff --git a/.pipelines/tests/resolveBuildMetadata.Tests.ps1 b/.pipelines/tests/resolveBuildMetadata.Tests.ps1 new file mode 100644 index 0000000000..0e237915d7 --- /dev/null +++ b/.pipelines/tests/resolveBuildMetadata.Tests.ps1 @@ -0,0 +1,268 @@ +# Copyright (c) Microsoft Corporation +# The Microsoft Corporation licenses this file to you under the MIT license. + +$scriptPath = Join-Path $PSScriptRoot "..\resolveBuildMetadata.ps1" + +function New-VersionProps { + param( + [string]$ReleaseTrain = "0.100", + [string]$Epoch = "2026-01-01" + ) + + $path = Join-Path $TestDrive "Version.props" + @" + + + $ReleaseTrain + $Epoch + + +"@ | Set-Content -LiteralPath $path + return $path +} + +function Assert-Throws { + param([scriptblock]$Action) + + $threw = $false + try { + & $Action | Out-Null + } + catch { + $threw = $true + } + + $threw | Should Be $true +} + +Describe "resolveBuildMetadata" { + It "generates the canonical preview version for main" { + $result = & $scriptPath ` + -SourceBranch "refs/heads/main" ` + -BuildReason "Schedule" ` + -BuildNumber "PowerToys Signed YAML Release Build_2607.30099-main" ` + -DailyVersionSequence "1" ` + -VersionPropsPath (New-VersionProps) + + $result.Intent | Should Be "preview-release" + $result.Channel | Should Be "preview" + $result.Version | Should Be "0.100.2111.0" + } + + It "uses the generated version by default for stable" { + $result = & $scriptPath ` + -SourceBranch "refs/heads/stable" ` + -BuildReason "Manual" ` + -BuildNumber "PowerToys Signed YAML Release Build_2607.30099-stable" ` + -DailyVersionSequence "2" ` + -VersionPropsPath (New-VersionProps) + + $result.Intent | Should Be "stable-release" + $result.Channel | Should Be "stable" + $result.Version | Should Be "0.100.2112.0" + } + + It "keeps private builds independent from the release counter" { + $result = & $scriptPath ` + -SourceBranch "refs/heads/user/feature" ` + -BuildReason "Manual" ` + -BuildNumber "PowerToys Signed YAML Release Build_2607.30003-feature" ` + -BuildDate "20260731" ` + -DailyVersionSequence "9" ` + -VersionPropsPath (New-VersionProps) + + $result.Version | Should Be "0.0.21103.0" + } + + It "allows an explicit MSI-safe version for private validation" { + $result = & $scriptPath ` + -VersionOverride "0.100.2151.0" ` + -SourceBranch "refs/heads/LegendaryBlair/preview-version" ` + -BuildReason "Manual" ` + -BuildNumber "PowerToys Signed YAML Release Build_2608.03001-preview-version" ` + -VersionPropsPath (New-VersionProps) + + $result.Intent | Should Be "private-validation" + $result.Channel | Should Be "private" + $result.Version | Should Be "0.100.2151.0" + $result.AllowPublicSymbols | Should Be $false + $result.ShouldPublishPreview | Should Be $false + } + + It "rejects a private override with a nonzero fourth component" { + Assert-Throws { + & $scriptPath ` + -VersionOverride "0.100.2151.1" ` + -SourceBranch "refs/heads/LegendaryBlair/preview-version" ` + -BuildReason "Manual" ` + -BuildNumber "PowerToys Signed YAML Release Build_2608.03001-preview-version" ` + -VersionPropsPath (New-VersionProps) + } + } + + It "increments the year digit across a calendar year boundary" { + $result = & $scriptPath ` + -SourceBranch "refs/heads/main" ` + -BuildReason "Manual" ` + -BuildNumber "PowerToys Signed YAML Release Build_2701.02001-main" ` + -DailyVersionSequence "1" ` + -VersionPropsPath (New-VersionProps) + + $result.Version | Should Be "0.100.10021.0" + } + + It "resets the year digit after the epoch advances with the release train" { + $result = & $scriptPath ` + -SourceBranch "refs/heads/main" ` + -BuildReason "Manual" ` + -BuildNumber "PowerToys Signed YAML Release Build_2701.02001-main" ` + -DailyVersionSequence "1" ` + -VersionPropsPath (New-VersionProps -ReleaseTrain "0.101" -Epoch "2027-01-01") + + $result.Version | Should Be "0.101.21.0" + } + + It "preserves monotonicity at the year boundary" { + $lastBuildOfYear = & $scriptPath ` + -SourceBranch "refs/heads/main" ` + -BuildReason "Manual" ` + -BuildNumber "PowerToys Signed YAML Release Build_2612.31099-main" ` + -DailyVersionSequence "9" ` + -VersionPropsPath (New-VersionProps) + $firstBuildOfNextYear = & $scriptPath ` + -SourceBranch "refs/heads/main" ` + -BuildReason "Manual" ` + -BuildNumber "PowerToys Signed YAML Release Build_2701.01001-main" ` + -DailyVersionSequence "1" ` + -VersionPropsPath (New-VersionProps) + + [int]($firstBuildOfNextYear.Version -split "\.")[2] | Should BeGreaterThan ([int]($lastBuildOfYear.Version -split "\.")[2]) + } + + It "preserves an explicit stable override" { + $result = & $scriptPath ` + -VersionOverride "0.100.2" ` + -SourceBranch "refs/heads/stable" ` + -BuildReason "Manual" ` + -BuildNumber "PowerToys Signed YAML Release Build_2607.30001-stable" ` + -DailyVersionSequence "1" ` + -VersionPropsPath (New-VersionProps) + + $result.Version | Should Be "0.100.2.0" + } + + It "rejects a stable override with a nonzero fourth component" { + Assert-Throws { + & $scriptPath ` + -VersionOverride "0.100.2.1" ` + -SourceBranch "refs/heads/stable" ` + -BuildReason "Manual" ` + -BuildNumber "PowerToys Signed YAML Release Build_2607.30001-stable" ` + -DailyVersionSequence "1" ` + -VersionPropsPath (New-VersionProps) + } + } + + It "rejects a stable override outside MSI major and minor limits" { + Assert-Throws { + & $scriptPath ` + -VersionOverride "0.256.2.0" ` + -SourceBranch "refs/heads/stable" ` + -BuildReason "Manual" ` + -BuildNumber "PowerToys Signed YAML Release Build_2607.30001-stable" ` + -DailyVersionSequence "1" ` + -VersionPropsPath (New-VersionProps) + } + } + + It "preserves a canonical full preview override" { + $result = & $scriptPath ` + -VersionOverride "0.100.2111.0" ` + -SourceBranch "refs/heads/main" ` + -BuildReason "Manual" ` + -BuildNumber "PowerToys Signed YAML Release Build_2607.30001-main" ` + -DailyVersionSequence "1" ` + -VersionPropsPath (New-VersionProps) + + $result.Version | Should Be "0.100.2111.0" + } + + It "rejects a preview override with a nonzero fourth component" { + Assert-Throws { + & $scriptPath ` + -VersionOverride "0.100.2111.1" ` + -SourceBranch "refs/heads/main" ` + -BuildReason "Manual" ` + -BuildNumber "PowerToys Signed YAML Release Build_2607.30001-main" ` + -DailyVersionSequence "1" ` + -VersionPropsPath (New-VersionProps) + } + } + + It "rejects a preview override from a different release train" { + Assert-Throws { + & $scriptPath ` + -VersionOverride "0.101.2111.0" ` + -SourceBranch "refs/heads/main" ` + -BuildReason "Manual" ` + -BuildNumber "PowerToys Signed YAML Release Build_2607.30001-main" ` + -DailyVersionSequence "1" ` + -VersionPropsPath (New-VersionProps) + } + } + + It "rejects daily release sequences above 9" { + Assert-Throws { + & $scriptPath ` + -SourceBranch "refs/heads/main" ` + -BuildReason "Manual" ` + -BuildNumber "PowerToys Signed YAML Release Build_2607.30001-main" ` + -DailyVersionSequence "10" ` + -VersionPropsPath (New-VersionProps) + } + } + + It "requires a release sequence for main and stable" { + Assert-Throws { + & $scriptPath ` + -SourceBranch "refs/heads/main" ` + -BuildReason "Manual" ` + -BuildNumber "PowerToys Signed YAML Release Build_2607.30001-main" ` + -VersionPropsPath (New-VersionProps) + } + } + + It "uses the pipeline date for both YDDD and counter alignment" { + $result = & $scriptPath ` + -SourceBranch "refs/heads/main" ` + -BuildReason "Manual" ` + -BuildNumber "PowerToys Signed YAML Release Build_2607.30001-main" ` + -BuildDate "20260731" ` + -DailyVersionSequence "1" ` + -VersionPropsPath (New-VersionProps) + + $result.Version | Should Be "0.100.2121.0" + } + + It "requires the epoch to be January 1" { + Assert-Throws { + & $scriptPath ` + -SourceBranch "refs/heads/main" ` + -BuildReason "Manual" ` + -BuildNumber "PowerToys Signed YAML Release Build_2607.30001-main" ` + -DailyVersionSequence "1" ` + -VersionPropsPath (New-VersionProps -Epoch "2026-02-01") + } + } + + It "rejects release trains that exceed the YDDDB year range" { + Assert-Throws { + & $scriptPath ` + -SourceBranch "refs/heads/main" ` + -BuildReason "Manual" ` + -BuildNumber "PowerToys Signed YAML Release Build_3301.01001-main" ` + -DailyVersionSequence "1" ` + -VersionPropsPath (New-VersionProps) + } + } +} diff --git a/.pipelines/v2/release.yml b/.pipelines/v2/release.yml index 72e16b72c9..df9f144b5d 100644 --- a/.pipelines/v2/release.yml +++ b/.pipelines/v2/release.yml @@ -16,9 +16,9 @@ parameters: default: false - name: versionNumber - displayName: "Version Number" + displayName: "Version Override (optional; main and stable default to the release-train version)" type: string - default: '0.0.1' + default: '' - name: buildConfigurations displayName: "Build Configurations" @@ -42,6 +42,14 @@ name: $(BuildDefinitionName)_$(date:yyMM).$(date:dd)$(rev:rrr) variables: - template: templates/variables-nuget-package-version.yml + - name: versionDate + value: $[format('{0:yyyyMMdd}', pipeline.startTime)] + - ${{ if or(eq(variables['Build.SourceBranch'], 'refs/heads/main'), eq(variables['Build.SourceBranch'], 'refs/heads/stable')) }}: + - name: dailyVersionSequence + value: $[counter(format('release-{0:yyyyMMdd}', pipeline.startTime), 1)] + - ${{ else }}: + - name: dailyVersionSequence + value: 0 extends: template: v1/1ES.Official.PipelineTemplate.yml@1ESPipelineTemplates @@ -110,7 +118,24 @@ extends: # Sets versions for all PowerToy created DLLs - pwsh: |- - .pipelines/versionSetting.ps1 -versionNumber '${{ parameters.versionNumber }}' -DevEnvironment '' + $metadata = .pipelines/resolveBuildMetadata.ps1 ` + -VersionOverride '${{ parameters.versionNumber }}' ` + -BuildDate '$(versionDate)' ` + -DailyVersionSequence '$(dailyVersionSequence)' + $publishSymbolsToPublic = $${{ parameters.publishSymbolsToPublic }} + if ($publishSymbolsToPublic -and -not $metadata.AllowPublicSymbols) { + throw "Public symbols are only supported for manually queued stable-branch releases" + } + + .pipelines/versionSetting.ps1 -versionNumber $metadata.Version -DevEnvironment '' -Channel $metadata.Channel + [xml]$versionProps = Get-Content 'src\Version.props' + $resolvedVersion = [string]$versionProps.Project.PropertyGroup.Version + Write-Host "Resolved PowerToys version: $resolvedVersion" + Write-Host "##vso[task.setvariable variable=ResolvedVersionNumber]$resolvedVersion" + Write-Host "##vso[task.setvariable variable=EffectiveVersionNumber]$resolvedVersion" + Write-Host "##vso[task.setvariable variable=ResolvedReleaseChannel]$($metadata.Channel)" + Write-Host "##vso[task.setvariable variable=ResolvedBuildIntent]$($metadata.Intent)" + Write-Host "##vso[task.setvariable variable=ShouldPublishPreview]$($metadata.ShouldPublishPreview)" displayName: Prepare versioning # Prepare the localizations and telemetry config before the release build diff --git a/.pipelines/v2/templates/job-build-project.yml b/.pipelines/v2/templates/job-build-project.yml index 3707cd662c..815b15ae4d 100644 --- a/.pipelines/v2/templates/job-build-project.yml +++ b/.pipelines/v2/templates/job-build-project.yml @@ -65,6 +65,9 @@ parameters: - name: versionNumber type: string default: '0.0.1' + - name: resolvedVersionNumber + type: string + default: '' - name: useLatestWinAppSDK type: boolean default: false @@ -140,6 +143,10 @@ jobs: JobOutputDirectory: $(Build.ArtifactStagingDirectory) LogOutputDirectory: $(Build.ArtifactStagingDirectory)\logs JobOutputArtifactName: build-$(BuildPlatform)-$(BuildConfiguration)${{ parameters.artifactStem }} + ${{ if eq(parameters.resolvedVersionNumber, '') }}: + EffectiveVersionNumber: ${{ parameters.versionNumber }} + ${{ if ne(parameters.resolvedVersionNumber, '') }}: + EffectiveVersionNumber: ${{ parameters.resolvedVersionNumber }} NUGET_RESTORE_MSBUILD_ARGS: /p:Platform=$(BuildPlatform) # Required for nuget to work due to self contained NODE_OPTIONS: --max_old_space_size=16384 ${{ if or(eq(parameters.runTests, true), eq(parameters.buildTests, true)) }}: @@ -599,7 +606,7 @@ jobs: parameters: codeSign: ${{ parameters.codeSign }} signingIdentity: ${{ parameters.signingIdentity }} - versionNumber: ${{ parameters.versionNumber }} + versionNumber: $(EffectiveVersionNumber) additionalBuildOptions: ${{ parameters.additionalBuildOptions }} # This saves ~1GiB per architecture. We won't need these later. @@ -663,7 +670,7 @@ jobs: # Publishing the GPO files - pwsh: |- - $GpoArchive = "$(JobOutputDirectory)\GroupPolicyObjectFiles-${{ parameters.versionNumber }}.zip" + $GpoArchive = "$(JobOutputDirectory)\GroupPolicyObjectFiles-$(EffectiveVersionNumber).zip" tar -c -v --format=zip -C .\src\gpo\assets -f $GpoArchive * displayName: Stage GPO files diff --git a/.pipelines/v2/templates/job-publish-symbols-using-symbolrequestprod-api.yml b/.pipelines/v2/templates/job-publish-symbols-using-symbolrequestprod-api.yml index 6b214be612..43c86a6604 100644 --- a/.pipelines/v2/templates/job-publish-symbols-using-symbolrequestprod-api.yml +++ b/.pipelines/v2/templates/job-publish-symbols-using-symbolrequestprod-api.yml @@ -11,6 +11,9 @@ parameters: - name: versionNumber type: string default: '0.0.1' + - name: resolvedVersionNumber + type: string + default: '' - name: artifactStem type: string default: '' @@ -39,7 +42,11 @@ jobs: dependsOn: ${{ parameters.dependsOn }} variables: ${{ insert }}: ${{ parameters.variables }} - SymbolsArtifactName: "PowerToys_${{parameters.versionNumber}}_$(Build.BuildNumber)" + ${{ if eq(parameters.resolvedVersionNumber, '') }}: + EffectiveVersionNumber: ${{ parameters.versionNumber }} + ${{ if ne(parameters.resolvedVersionNumber, '') }}: + EffectiveVersionNumber: ${{ parameters.resolvedVersionNumber }} + SymbolsArtifactName: "PowerToys_$(EffectiveVersionNumber)_$(Build.BuildNumber)" steps: - checkout: self clean: true @@ -48,6 +55,28 @@ jobs: submodules: true persistCredentials: True + - pwsh: |- + $effectiveVersionNumber = '${{ parameters.resolvedVersionNumber }}' + if ([string]::IsNullOrWhiteSpace($effectiveVersionNumber) -or $effectiveVersionNumber.StartsWith('$(')) { + $metadata = .pipelines/resolveBuildMetadata.ps1 ` + -VersionOverride '${{ parameters.versionNumber }}' ` + -BuildDate '$(versionDate)' ` + -DailyVersionSequence '$(dailyVersionSequence)' + if ($${{ parameters.includePublicSymbolServer }} -and -not $metadata.AllowPublicSymbols) { + throw "Public symbols are only supported for manually queued stable-branch releases" + } + + $effectiveVersionNumber = $metadata.Version + } + + if ($effectiveVersionNumber -match '^(?\d+\.\d+(?:\.\d+){0,2})-preview$') { + $effectiveVersionNumber = $matches['numeric'] + } + + Write-Host "##vso[task.setvariable variable=EffectiveVersionNumber]$effectiveVersionNumber" + Write-Host "##vso[task.setvariable variable=SymbolsArtifactName]PowerToys_${effectiveVersionNumber}_$(Build.BuildNumber)" + displayName: Resolve symbol version + - task: DownloadPipelineArtifact@2 displayName: Download all PDBs from all prior build phases inputs: @@ -83,7 +112,7 @@ jobs: SymbolsMaximumWaitTime: 30 SymbolServerType: 'TeamServices' SymbolsProduct: 'PowerToys Converged Symbols' - SymbolsVersion: '${{ parameters.versionNumber }}' + SymbolsVersion: '$(EffectiveVersionNumber)' SymbolsArtifactName: $(SymbolsArtifactName) SymbolExpirationInDays: ${{ parameters.symbolExpiryTime }} env: diff --git a/.pipelines/versionSetting.ps1 b/.pipelines/versionSetting.ps1 index cf2d2595af..257f704e07 100644 --- a/.pipelines/versionSetting.ps1 +++ b/.pipelines/versionSetting.ps1 @@ -5,37 +5,85 @@ Param( [Parameter(Mandatory=$True,Position=2)] [AllowEmptyString()] - [string]$DevEnvironment = "Local" + [string]$DevEnvironment = "Local", + + [ValidateSet("stable", "preview", "private")] + [string]$Channel = "stable", + + [string]$SourceCommit = $env:BUILD_SOURCEVERSION, + + [string]$BuildNumber = $env:BUILD_BUILDNUMBER, + + [string]$BuildDate = $env:VERSIONDATE, + + [string]$DailyVersionSequence = $env:DAILYVERSIONSEQUENCE ) Write-Host $PSScriptRoot -$versionRegex = "(\d+)\.(\d+)\.(\d+)" -if($versionNumber -match $versionRegEx) -{ - #$buildDayOfYear = (Get-Date).DayofYear; - #$buildTime = Get-Date -Format HH; - #$buildTime = Get-Date -Format HHmmss; - #$buildYear = Get-Date -Format yy; - #$revision = [string]::Format("{0}{1}{2}", $buildYear, $buildDayOfYear, $buildTime ) +function Get-NormalizedVersion { + param( + [Parameter(Mandatory = $true)] + [string]$InputVersion, + [Parameter(Mandatory = $true)] + [string]$ReleaseChannel, + [string]$PipelineBuildNumber, + [string]$PipelineBuildDate, + [string]$PipelineDailyVersionSequence + ) - # max UInt16, 65535 - #$revision = [string]::Format("{0}{1}", $buildDayOfYear, $buildTime ) - #Write-Host "Revision" $revision + if ($InputVersion -match "^(?\d+\.\d+(?:\.\d+){0,2})-(?preview)$") { + if ($ReleaseChannel -ne "preview") { + throw "Version suffix '-preview' can only be used with the preview release channel" + } - $versionNumber = [int]::Parse($matches[1]).ToString() + "." + [int]::Parse($matches[2]).ToString() + "." + [int]::Parse($matches[3]).ToString() # + "." + $revision - Write-Host "Version Number" $versionNumber + $InputVersion = $matches["numeric"] + } + + if ($ReleaseChannel -eq "preview" -and $InputVersion -match "^(\d+)\.(\d+)$") { + $metadata = & (Join-Path $PSScriptRoot "resolveBuildMetadata.ps1") ` + -VersionOverride $InputVersion ` + -SourceBranch "refs/heads/main" ` + -BuildReason "Manual" ` + -BuildNumber $PipelineBuildNumber ` + -BuildDate $PipelineBuildDate ` + -DailyVersionSequence $PipelineDailyVersionSequence + return $metadata.Version + } + + if ($InputVersion -match "^(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?$") { + $versionParts = @([int]::Parse($matches[1]), [int]::Parse($matches[2]), [int]::Parse($matches[3])) + if ($matches[4]) { + $versionParts += [int]::Parse($matches[4]) + } + + return $versionParts -join "." + } + + throw "Build format does not match the expected pattern (w.x, w.x.y, w.x.y.z, or w.x.y.z-preview for preview channel)" } -else -{ - throw "Build format does not match the expected pattern (buildName_w.x.y.z)" + +$versionNumber = Get-NormalizedVersion ` + -InputVersion $versionNumber ` + -ReleaseChannel $Channel ` + -PipelineBuildNumber $BuildNumber ` + -PipelineBuildDate $BuildDate ` + -PipelineDailyVersionSequence $DailyVersionSequence +foreach ($part in ($versionNumber -split '\.')) { + $value = [int]::Parse($part) + if ($value -lt 0 -or $value -gt [UInt16]::MaxValue) { + throw "Version component '$value' is outside the supported Windows version range 0-65535" + } } +Write-Host "Version Number" $versionNumber $verPropWriteFileLocation = $PSScriptRoot + '/../src/Version.props'; $verPropReadFileLocation = $verPropWriteFileLocation; [XML]$verProps = Get-Content $verPropReadFileLocation $verProps.Project.PropertyGroup.Version = $versionNumber; +$verProps.Project.PropertyGroup.VersionChannel = $Channel; +$verProps.Project.PropertyGroup.SourceCommit = if ([string]::IsNullOrWhiteSpace($SourceCommit)) { "" } else { $SourceCommit }; $verProps.Project.PropertyGroup.DevEnvironment = $DevEnvironment; Write-Host "xml" $verProps.Project.PropertyGroup.Version @@ -51,12 +99,14 @@ Write-Host "xml" $verProps.Project.PropertyGroup.Version $verProps.Save($verPropWriteFileLocation); ####### +$manifestVersionNumber = if (($versionNumber.ToCharArray() | Where-Object { $_ -eq '.' }).Count -eq 2) { $versionNumber + '.0' } else { $versionNumber } + # Set PowerRenameContextMenu package version in AppManifest.xml $powerRenameContextMenuAppManifestWriteFileLocation = $PSScriptRoot + '/../src/modules/powerrename/PowerRenameContextMenu/AppxManifest.xml'; $powerRenameContextMenuAppManifestReadFileLocation = $powerRenameContextMenuAppManifestWriteFileLocation; [XML]$powerRenameContextMenuAppManifest = Get-Content $powerRenameContextMenuAppManifestReadFileLocation -$powerRenameContextMenuAppManifest.Package.Identity.Version = $versionNumber + '.0' +$powerRenameContextMenuAppManifest.Package.Identity.Version = $manifestVersionNumber Write-Host "PowerRenameContextMenu version" $powerRenameContextMenuAppManifest.Package.Identity.Version $powerRenameContextMenuAppManifest.Save($powerRenameContextMenuAppManifestWriteFileLocation); @@ -65,7 +115,7 @@ $imageResizerContextMenuAppManifestWriteFileLocation = $PSScriptRoot + '/../src/ $imageResizerContextMenuAppManifestReadFileLocation = $imageResizerContextMenuAppManifestWriteFileLocation; [XML]$imageResizerContextMenuAppManifest = Get-Content $imageResizerContextMenuAppManifestReadFileLocation -$imageResizerContextMenuAppManifest.Package.Identity.Version = $versionNumber + '.0' +$imageResizerContextMenuAppManifest.Package.Identity.Version = $manifestVersionNumber Write-Host "ImageResizerContextMenu version" $imageResizerContextMenuAppManifest.Package.Identity.Version $imageResizerContextMenuAppManifest.Save($imageResizerContextMenuAppManifestWriteFileLocation); @@ -74,7 +124,7 @@ $fileLocksmithContextMenuAppManifestWriteFileLocation = $PSScriptRoot + '/../src $fileLocksmithContextMenuAppManifestReadFileLocation = $fileLocksmithContextMenuAppManifestWriteFileLocation; [XML]$fileLocksmithContextMenuAppManifest = Get-Content $fileLocksmithContextMenuAppManifestReadFileLocation -$fileLocksmithContextMenuAppManifest.Package.Identity.Version = $versionNumber + '.0' +$fileLocksmithContextMenuAppManifest.Package.Identity.Version = $manifestVersionNumber Write-Host "FileLocksmithContextMenu version" $fileLocksmithContextMenuAppManifest.Package.Identity.Version $fileLocksmithContextMenuAppManifest.Save($fileLocksmithContextMenuAppManifestWriteFileLocation); @@ -83,6 +133,6 @@ $newPlusContextMenuAppManifestWriteFileLocation = $PSScriptRoot + '/../src/modul $newPlusContextMenuAppManifestReadFileLocation = $newPlusContextMenuAppManifestWriteFileLocation; [XML]$newPlusContextMenuAppManifest = Get-Content $newPlusContextMenuAppManifestReadFileLocation -$newPlusContextMenuAppManifest.Package.Identity.Version = $versionNumber + '.0' +$newPlusContextMenuAppManifest.Package.Identity.Version = $manifestVersionNumber Write-Host "NewPlusContextMenu version" $newPlusContextMenuAppManifest.Package.Identity.Version $newPlusContextMenuAppManifest.Save($newPlusContextMenuAppManifestWriteFileLocation); diff --git a/Directory.Build.props b/Directory.Build.props index 8745c1c311..8106709ced 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -18,7 +18,7 @@ Recommended <_SkipUpgradeNetAnalyzersNuGetWarning>true direct - false + false $(Platform) false @@ -74,7 +74,9 @@ - $(Version).0 + .0 + + $(Version)$(VersionBuildSuffix) https://github.com/microsoft/PowerToys GitHub true diff --git a/installer/PowerToysSetupVNext/Core.wxs b/installer/PowerToysSetupVNext/Core.wxs index 87c6566c13..25e2e16a26 100644 --- a/installer/PowerToysSetupVNext/Core.wxs +++ b/installer/PowerToysSetupVNext/Core.wxs @@ -83,11 +83,11 @@ - + - - + + diff --git a/installer/PowerToysSetupVNext/DscResources.wxs b/installer/PowerToysSetupVNext/DscResources.wxs index 0a3123c3a9..0566b13532 100644 --- a/installer/PowerToysSetupVNext/DscResources.wxs +++ b/installer/PowerToysSetupVNext/DscResources.wxs @@ -12,8 +12,8 @@ - - + + diff --git a/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj b/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj index 4000503edf..45cfbdcbaa 100644 --- a/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj +++ b/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj @@ -3,8 +3,13 @@ false + + .0 + + $(Version)$(DscModuleVersionSuffix) + - Version=$(Version);MonacoSRCHarvestPath=$(ProjectDir)..\..\x64\$(Configuration)\Assets\Monaco\monacoSRC;CmdPalVersion=$(CmdPalVersion) @@ -17,7 +22,7 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil - Version=$(Version);MonacoSRCHarvestPath=$(ProjectDir)..\..\ARM64\$(Configuration)\Assets\Monaco\monacoSRC;CmdPalVersion=$(CmdPalVersion) + Version=$(Version);DscModuleVersion=$(DscModuleVersion);MonacoSRCHarvestPath=$(ProjectDir)..\..\ARM64\$(Configuration)\Assets\Monaco\monacoSRC;CmdPalVersion=$(CmdPalVersion) IF NOT DEFINED IsPipeline ( call "$([MSBuild]::GetVsInstallRoot())\Common7\Tools\VsDevCmd.bat" -arch=arm64 -host_arch=amd64 -winsdk=10.0.19041.0 -vcvars_ver=$(VCToolsVersion) SET PTRoot=$(SolutionDir)\.. diff --git a/src/Update/PowerToys.Update.cpp b/src/Update/PowerToys.Update.cpp index 7c3589af1d..9cc316cfd2 100644 --- a/src/Update/PowerToys.Update.cpp +++ b/src/Update/PowerToys.Update.cpp @@ -146,7 +146,8 @@ std::optional ObtainInstaller(bool& isUpToDate) return std::nullopt; } - const auto new_version_info = std::move(get_github_version_info_async()).get(); + const bool include_prerelease_updates = PTSettingsHelper::load_general_settings().GetNamedBoolean(L"include_prerelease_updates", false); + const auto new_version_info = std::move(get_github_version_info_async(include_prerelease_updates)).get(); // Check for error BEFORE dereferencing — the old code crashed here // when GitHub API was unreachable (new_version_info held an error string). diff --git a/src/Version.props b/src/Version.props index 2042a73fbd..f5991322b0 100644 --- a/src/Version.props +++ b/src/Version.props @@ -2,6 +2,12 @@ 0.0.1 + private + + 0.100 + + 2026-01-01 + Local diff --git a/src/common/GPOWrapper/GPOWrapper.cpp b/src/common/GPOWrapper/GPOWrapper.cpp index 0f4ad1f9e9..8d6844c3df 100644 --- a/src/common/GPOWrapper/GPOWrapper.cpp +++ b/src/common/GPOWrapper/GPOWrapper.cpp @@ -180,6 +180,10 @@ namespace winrt::PowerToys::GPOWrapper::implementation { return static_cast(powertoys_gpo::getDisableAutomaticUpdateDownloadValue()); } + GpoRuleConfigured GPOWrapper::GetDisablePreviewUpdatesValue() + { + return static_cast(powertoys_gpo::getDisablePreviewUpdatesValue()); + } GpoRuleConfigured GPOWrapper::GetDisableShowWhatsNewAfterUpdatesValue() { return static_cast(powertoys_gpo::getDisableShowWhatsNewAfterUpdatesValue()); diff --git a/src/common/GPOWrapper/GPOWrapper.h b/src/common/GPOWrapper/GPOWrapper.h index b1af9a00d1..616b523e16 100644 --- a/src/common/GPOWrapper/GPOWrapper.h +++ b/src/common/GPOWrapper/GPOWrapper.h @@ -51,6 +51,7 @@ namespace winrt::PowerToys::GPOWrapper::implementation static GpoRuleConfigured GetConfiguredPeekEnabledValue(); static GpoRuleConfigured GetDisableNewUpdateToastValue(); static GpoRuleConfigured GetDisableAutomaticUpdateDownloadValue(); + static GpoRuleConfigured GetDisablePreviewUpdatesValue(); static GpoRuleConfigured GetDisableShowWhatsNewAfterUpdatesValue(); static GpoRuleConfigured GetAllowExperimentationValue(); static GpoRuleConfigured GetRunPluginEnabledValue(winrt::hstring const& pluginID); diff --git a/src/common/GPOWrapper/GPOWrapper.idl b/src/common/GPOWrapper/GPOWrapper.idl index 157f57f859..33d6821673 100644 --- a/src/common/GPOWrapper/GPOWrapper.idl +++ b/src/common/GPOWrapper/GPOWrapper.idl @@ -55,6 +55,7 @@ namespace PowerToys static GpoRuleConfigured GetConfiguredPeekEnabledValue(); static GpoRuleConfigured GetDisableNewUpdateToastValue(); static GpoRuleConfigured GetDisableAutomaticUpdateDownloadValue(); + static GpoRuleConfigured GetDisablePreviewUpdatesValue(); static GpoRuleConfigured GetDisableShowWhatsNewAfterUpdatesValue(); static GpoRuleConfigured GetAllowExperimentationValue(); static GpoRuleConfigured GetRunPluginEnabledValue(String pluginID); diff --git a/src/common/UnitTests-CommonLib/UnitTestsVersionHelper.cpp b/src/common/UnitTests-CommonLib/UnitTestsVersionHelper.cpp index 3a77cefd71..29fb6d58ba 100644 --- a/src/common/UnitTests-CommonLib/UnitTestsVersionHelper.cpp +++ b/src/common/UnitTests-CommonLib/UnitTestsVersionHelper.cpp @@ -29,6 +29,7 @@ namespace UnitTestsVersionHelper Assert::AreEqual(MAJOR_VERSION_0, sut.major); Assert::AreEqual(MINOR_VERSION_12, sut.minor); Assert::AreEqual(REVISION_VERSION_0, sut.revision); + Assert::AreEqual(0ull, sut.build); } TEST_METHOD (integerConstructorShouldProperlyInitializationWithDifferentVersionNumbers) { @@ -40,6 +41,16 @@ namespace UnitTestsVersionHelper Assert::AreEqual(testcaseMajor, sut.major); Assert::AreEqual(testcaseMinor, sut.minor); Assert::AreEqual(testcaseRevision, sut.revision); + Assert::AreEqual(0ull, sut.build); + } + TEST_METHOD (integerConstructorShouldProperlyInitializeBuildVersionNumber) + { + VersionHelper sut(0, 100, 2607, 8001); + + Assert::AreEqual(0ull, sut.major); + Assert::AreEqual(100ull, sut.minor); + Assert::AreEqual(2607ull, sut.revision); + Assert::AreEqual(8001ull, sut.build); } TEST_METHOD (stringConstructorShouldProperlyInitializationVersionNumbers) { @@ -48,6 +59,25 @@ namespace UnitTestsVersionHelper Assert::AreEqual(0ull, sut->major); Assert::AreEqual(12ull, sut->minor); Assert::AreEqual(3ull, sut->revision); + Assert::AreEqual(0ull, sut->build); + } + TEST_METHOD (stringConstructorShouldProperlyInitializationFourPartVersionNumbers) + { + auto sut = VersionHelper::fromString("v0.100.2607.08001"); + Assert::IsTrue(sut.has_value()); + Assert::AreEqual(0ull, sut->major); + Assert::AreEqual(100ull, sut->minor); + Assert::AreEqual(2607ull, sut->revision); + Assert::AreEqual(8001ull, sut->build); + } + TEST_METHOD (stringConstructorShouldIgnorePreviewSuffix) + { + auto sut = VersionHelper::fromString("v0.100.2607.08001-preview"); + Assert::IsTrue(sut.has_value()); + Assert::AreEqual(0ull, sut->major); + Assert::AreEqual(100ull, sut->minor); + Assert::AreEqual(2607ull, sut->revision); + Assert::AreEqual(8001ull, sut->build); } TEST_METHOD (stringConstructorShouldProperlyInitializationWithDifferentVersionNumbers) { @@ -121,6 +151,12 @@ namespace UnitTestsVersionHelper Assert::IsFalse(sut.has_value()); } + TEST_METHOD (tooManyVersionPartsNotAccepted) + { + auto sut = VersionHelper::fromString(L"v1.2.3.4.5"); + + Assert::IsFalse(sut.has_value()); + } TEST_METHOD (parsedWithoutLeadingV) { VersionHelper expected{ 12ull, 13ull, 111ull }; @@ -129,6 +165,14 @@ namespace UnitTestsVersionHelper Assert::IsTrue(actual.has_value()); Assert::AreEqual(*actual, expected); } + TEST_METHOD (parsedFourPartVersionWithoutLeadingV) + { + VersionHelper expected{ 12ull, 13ull, 111ull, 5ull }; + auto actual = VersionHelper::fromString(L"12.13.111.5"); + + Assert::IsTrue(actual.has_value()); + Assert::AreEqual(*actual, expected); + } TEST_METHOD (whenMajorVersionIsGreaterComparisonOperatorShouldReturnProperValue) { VersionHelper lhs(MAJOR_VERSION_0 + 1, MINOR_VERSION_12, REVISION_VERSION_0); @@ -179,5 +223,24 @@ namespace UnitTestsVersionHelper Assert::IsFalse(lhs > rhs); } + TEST_METHOD (whenMajorMinorAndRevisionAreEqualComparisonOperatorShouldCompareBuildValue) + { + VersionHelper lhs(MAJOR_VERSION_0, MINOR_VERSION_12, REVISION_VERSION_0, 1); + VersionHelper rhs(MAJOR_VERSION_0, MINOR_VERSION_12, REVISION_VERSION_0); + + Assert::IsTrue(lhs > rhs); + } + TEST_METHOD (zeroBuildVersionShouldFormatAsThreePartVersion) + { + VersionHelper sut(MAJOR_VERSION_0, MINOR_VERSION_12, REVISION_VERSION_0); + + Assert::AreEqual(std::wstring(L"v0.12.0"), sut.toWstring()); + } + TEST_METHOD (nonZeroBuildVersionShouldFormatAsFourPartVersion) + { + VersionHelper sut(MAJOR_VERSION_0, MINOR_VERSION_12, REVISION_VERSION_0, 1); + + Assert::AreEqual(std::wstring(L"v0.12.0.1"), sut.toWstring()); + } }; } diff --git a/src/common/interop/CommonManaged.cpp b/src/common/interop/CommonManaged.cpp index d59741fef6..003f99fbd0 100644 --- a/src/common/interop/CommonManaged.cpp +++ b/src/common/interop/CommonManaged.cpp @@ -9,4 +9,14 @@ namespace winrt::PowerToys::Interop::implementation { return hstring{ get_product_version() }; } + + hstring CommonManaged::GetProductVersionChannel() + { + return hstring{ get_product_version_channel() }; + } + + hstring CommonManaged::GetProductVersionSourceCommit() + { + return hstring{ get_product_version_source_commit() }; + } } diff --git a/src/common/interop/CommonManaged.h b/src/common/interop/CommonManaged.h index 7196699ae9..f558155877 100644 --- a/src/common/interop/CommonManaged.h +++ b/src/common/interop/CommonManaged.h @@ -8,6 +8,8 @@ namespace winrt::PowerToys::Interop::implementation CommonManaged() = default; static hstring GetProductVersion(); + static hstring GetProductVersionChannel(); + static hstring GetProductVersionSourceCommit(); }; } namespace winrt::PowerToys::Interop::factory_implementation diff --git a/src/common/interop/CommonManaged.idl b/src/common/interop/CommonManaged.idl index 38e9225bb5..023b5abb58 100644 --- a/src/common/interop/CommonManaged.idl +++ b/src/common/interop/CommonManaged.idl @@ -4,6 +4,8 @@ namespace PowerToys { [default_interface] static runtimeclass CommonManaged { static String GetProductVersion(); + static String GetProductVersionChannel(); + static String GetProductVersionSourceCommit(); } } } \ No newline at end of file diff --git a/src/common/updating/installer.cpp b/src/common/updating/installer.cpp index c94cf1f08e..11f27cc311 100644 --- a/src/common/updating/installer.cpp +++ b/src/common/updating/installer.cpp @@ -41,11 +41,11 @@ namespace updating try { auto packages = package_manager.FindPackagesForUser({}, MSIX_PACKAGE_NAME, MSIX_PACKAGE_PUBLISHER); - VersionHelper current_version(VERSION_MAJOR, VERSION_MINOR, VERSION_REVISION); + VersionHelper current_version(VERSION_MAJOR, VERSION_MINOR, VERSION_REVISION, VERSION_BUILD); for (auto package : packages) { - VersionHelper msix_version(package.Id().Version().Major, package.Id().Version().Minor, package.Id().Version().Revision); + VersionHelper msix_version(package.Id().Version().Major, package.Id().Version().Minor, package.Id().Version().Build, package.Id().Version().Revision); if (msix_version < current_version) { diff --git a/src/common/updating/updateState.cpp b/src/common/updating/updateState.cpp index e9bea79447..2f8d53d053 100644 --- a/src/common/updating/updateState.cpp +++ b/src/common/updating/updateState.cpp @@ -11,7 +11,7 @@ namespace { const wchar_t PERSISTENT_STATE_FILENAME[] = L"\\UpdateState.json"; const wchar_t UPDATE_STATE_MUTEX[] = L"Local\\PowerToysRunnerUpdateStateMutex"; - const VersionHelper CURRENT_VERSION(VERSION_MAJOR, VERSION_MINOR, VERSION_REVISION); + const VersionHelper CURRENT_VERSION(VERSION_MAJOR, VERSION_MINOR, VERSION_REVISION, VERSION_BUILD); } UpdateState deserialize(const json::JsonObject& json) @@ -22,6 +22,7 @@ UpdateState deserialize(const json::JsonObject& json) result.releasePageUrl = json.GetNamedString(L"releasePageUrl", L""); result.githubUpdateLastCheckedDate = timeutil::from_string(json.GetNamedString(L"githubUpdateLastCheckedDate", L"invalid").c_str()); result.downloadedInstallerFilename = json.GetNamedString(L"downloadedInstallerFilename", L""); + result.isPrerelease = json.GetNamedBoolean(L"isPrerelease", false); return result; } @@ -36,6 +37,7 @@ json::JsonObject serialize(const UpdateState& state) json.SetNamedValue(L"releasePageUrl", json::value(state.releasePageUrl)); json.SetNamedValue(L"state", json::value(static_cast(state.state))); json.SetNamedValue(L"downloadedInstallerFilename", json::value(state.downloadedInstallerFilename)); + json.SetNamedValue(L"isPrerelease", json::value(state.isPrerelease)); json.SetNamedValue(L"updateStateFileVersion", json::value(CURRENT_VERSION.toWstring())); diff --git a/src/common/updating/updateState.h b/src/common/updating/updateState.h index 6aba8d969b..7761fd5a50 100644 --- a/src/common/updating/updateState.h +++ b/src/common/updating/updateState.h @@ -18,6 +18,7 @@ struct UpdateState std::wstring releasePageUrl; std::optional githubUpdateLastCheckedDate; std::wstring downloadedInstallerFilename; + bool isPrerelease = false; // To prevent concurrent modification of the file, we enforce this interface, which locks the file while // the state_modifier is active. diff --git a/src/common/updating/updating.cpp b/src/common/updating/updating.cpp index d26822a5f1..b03638344c 100644 --- a/src/common/updating/updating.cpp +++ b/src/common/updating/updating.cpp @@ -16,7 +16,7 @@ using namespace registry::install_scope; namespace // Strings in this namespace should not be localized { const wchar_t LATEST_RELEASE_ENDPOINT[] = L"https://api.github.com/repos/microsoft/PowerToys/releases/latest"; - const wchar_t ALL_RELEASES_ENDPOINT[] = L"https://api.github.com/repos/microsoft/PowerToys/releases"; + const wchar_t ALL_RELEASES_ENDPOINT[] = L"https://api.github.com/repos/microsoft/PowerToys/releases?per_page=100"; const wchar_t LOCAL_BUILD_ERROR[] = L"Local build cannot be updated"; const wchar_t NETWORK_ERROR[] = L"Network error"; @@ -82,7 +82,7 @@ namespace updating // prevent the warning that may show up depend on the value of the constants (#defines) #pragma warning(push) #pragma warning(disable : 4702) - wil::task get_github_version_info_async(const bool prerelease) + wil::task get_github_version_info_async(const bool include_prerelease) { // If the current version starts with 0.0.*, it means we're on a local build from a farm and shouldn't check for updates. if constexpr (VERSION_MAJOR == 0 && VERSION_MINOR == 0) @@ -94,18 +94,17 @@ namespace updating { http::HttpClient client; json::JsonObject release_object; - const VersionHelper current_version(VERSION_MAJOR, VERSION_MINOR, VERSION_REVISION); + const VersionHelper current_version(VERSION_MAJOR, VERSION_MINOR, VERSION_REVISION, VERSION_BUILD); VersionHelper github_version = current_version; - if (prerelease) + if (include_prerelease) { const auto body = co_await client.request(Uri{ ALL_RELEASES_ENDPOINT }); for (const auto& json : json::JsonValue::Parse(body).GetArray()) { auto potential_release_object = json.GetObjectW(); - const bool is_prerelease = potential_release_object.GetNamedBoolean(L"prerelease", false); auto extracted_version = extract_version_from_release_object(potential_release_object); - if (!is_prerelease || !extracted_version || *extracted_version <= github_version) + if (!extracted_version || *extracted_version <= github_version) { continue; } @@ -134,7 +133,8 @@ namespace updating co_return new_version_download_info{ extract_release_page_url(release_object), std::move(github_version), std::move(installer_download_url), - std::move(installer_filename) }; + std::move(installer_filename), + release_object.GetNamedBoolean(L"prerelease", false) }; } catch (...) { diff --git a/src/common/updating/updating.h b/src/common/updating/updating.h index b3ef2552f3..bab48e15df 100644 --- a/src/common/updating/updating.h +++ b/src/common/updating/updating.h @@ -19,14 +19,15 @@ namespace updating struct new_version_download_info { Uri release_page_uri = nullptr; - VersionHelper version{ 0, 0, 0 }; + VersionHelper version{ 0, 0, 0, 0 }; Uri installer_download_url = nullptr; std::wstring installer_filename; + bool is_prerelease = false; }; using github_version_info = std::variant; using github_version_result = std::expected; - wil::task get_github_version_info_async(bool prerelease = false); + wil::task get_github_version_info_async(bool include_prerelease = false); wil::task> download_new_version_async(new_version_download_info new_version); std::filesystem::path get_pending_updates_path(); void cleanup_updates(); diff --git a/src/common/utils/gpo.h b/src/common/utils/gpo.h index 1d420596c4..a7ae4bb00f 100644 --- a/src/common/utils/gpo.h +++ b/src/common/utils/gpo.h @@ -80,6 +80,7 @@ namespace powertoys_gpo const std::wstring POLICY_SUSPEND_NEW_UPDATE_TOAST = L"SuspendNewUpdateAvailableToast"; const std::wstring POLICY_DISABLE_NEW_UPDATE_TOAST = L"DisableNewUpdateAvailableToast"; const std::wstring POLICY_DISABLE_SHOW_WHATS_NEW_AFTER_UPDATES = L"DoNotShowWhatsNewAfterUpdates"; + const std::wstring POLICY_DISABLE_PREVIEW_UPDATES = L"PreviewUpdatesDisabled"; // The registry value names for other PowerToys policies. const std::wstring POLICY_ALLOW_EXPERIMENTATION = L"AllowExperimentation"; @@ -548,6 +549,11 @@ namespace powertoys_gpo return getConfiguredValue(POLICY_DISABLE_SHOW_WHATS_NEW_AFTER_UPDATES); } + inline gpo_rule_configured_t getDisablePreviewUpdatesValue() + { + return getConfiguredValue(POLICY_DISABLE_PREVIEW_UPDATES); + } + inline gpo_rule_configured_t getAllowExperimentationValue() { return getConfiguredValue(POLICY_ALLOW_EXPERIMENTATION); diff --git a/src/common/utils/package.h b/src/common/utils/package.h index 6db77d593f..88d1189d4e 100644 --- a/src/common/utils/package.h +++ b/src/common/utils/package.h @@ -182,7 +182,7 @@ namespace package if (packageFullName.contains(packageDisplayName)) { // If checkVersion is true, verify if the package has the same version as PowerToys. - if ((!checkVersion) || (packageVersion.Major == VERSION_MAJOR && packageVersion.Minor == VERSION_MINOR && packageVersion.Revision == VERSION_REVISION)) + if ((!checkVersion) || (packageVersion.Major == VERSION_MAJOR && packageVersion.Minor == VERSION_MINOR && packageVersion.Build == VERSION_REVISION && packageVersion.Revision == VERSION_BUILD)) { return { package }; } diff --git a/src/common/version/helper.cpp b/src/common/version/helper.cpp index 1501956e43..71dd0a9aeb 100644 --- a/src/common/version/helper.cpp +++ b/src/common/version/helper.cpp @@ -3,12 +3,12 @@ #include "../utils/string_utils.h" #include -#include -VersionHelper::VersionHelper(const size_t major, const size_t minor, const size_t revision) : +VersionHelper::VersionHelper(const size_t major, const size_t minor, const size_t revision, const size_t build) : major{ major }, minor{ minor }, - revision{ revision } + revision{ revision }, + build{ build } { } @@ -40,17 +40,42 @@ std::optional fromString(std::basic_string_view str) { str = left_trim(trim(str), Constants::LOWER_V); str = left_trim(trim(str), Constants::UPPER_V); - std::basic_string spacedStr{ str }; - replace_chars(spacedStr, Constants::DOT, Constants::SPACE); - - std::basic_istringstream ss{ spacedStr }; - VersionHelper result{ 0, 0, 0 }; - ss >> result.major; - ss >> result.minor; - ss >> result.revision; - if (!ss.fail() && ss.eof()) + if (const auto suffixPos = str.find(static_cast('-')); suffixPos != std::basic_string_view::npos) { - return result; + str = str.substr(0, suffixPos); + } + + size_t parts[4]{}; + size_t partCount = 0; + size_t start = 0; + while (start <= str.size() && partCount < std::size(parts)) + { + const auto dot = str.find(Constants::DOT[0], start); + const auto end = dot == std::basic_string_view::npos ? str.size() : dot; + const auto part = str.substr(start, end - start); + if (part.empty() || !std::all_of(part.begin(), part.end(), [](const CharT c) { return c >= static_cast('0') && c <= static_cast('9'); })) + { + return std::nullopt; + } + + parts[partCount++] = static_cast(std::stoull(std::basic_string{ part })); + + if (dot == std::basic_string_view::npos) + { + start = str.size() + 1; + break; + } + start = dot + 1; + } + + if (partCount == 3 && start > str.size()) + { + return VersionHelper{ parts[0], parts[1], parts[2] }; + } + + if (partCount == 4 && start > str.size()) + { + return VersionHelper{ parts[0], parts[1], parts[2], parts[3] }; } } catch (...) @@ -77,6 +102,11 @@ std::wstring VersionHelper::toWstring() const result += std::to_wstring(minor); result += L'.'; result += std::to_wstring(revision); + if (build != 0) + { + result += L'.'; + result += std::to_wstring(build); + } return result; } @@ -88,5 +118,10 @@ std::string VersionHelper::toString() const result += std::to_string(minor); result += '.'; result += std::to_string(revision); + if (build != 0) + { + result += '.'; + result += std::to_string(build); + } return result; } diff --git a/src/common/version/helper.h b/src/common/version/helper.h index 4abd464ac8..c99fcfc8b1 100644 --- a/src/common/version/helper.h +++ b/src/common/version/helper.h @@ -6,7 +6,7 @@ struct VersionHelper { - VersionHelper(const size_t major, const size_t minor, const size_t revision); + VersionHelper(const size_t major, const size_t minor, const size_t revision, const size_t build = 0); auto operator<=>(const VersionHelper&) const = default; @@ -16,6 +16,7 @@ struct VersionHelper size_t major; size_t minor; size_t revision; + size_t build; std::wstring toWstring() const; std::string toString() const; diff --git a/src/common/version/version.h b/src/common/version/version.h index d57b2eae6c..f9fcf73507 100644 --- a/src/common/version/version.h +++ b/src/common/version/version.h @@ -5,10 +5,10 @@ #include "Generated Files\version_gen.h" -#define FILE_VERSION VERSION_MAJOR, VERSION_MINOR, VERSION_REVISION, 0 +#define FILE_VERSION VERSION_MAJOR, VERSION_MINOR, VERSION_REVISION, VERSION_BUILD #define FILE_VERSION_STRING \ STRINGIZE(VERSION_MAJOR) \ - "." STRINGIZE(VERSION_MINOR) "." STRINGIZE(VERSION_REVISION) ".0" + "." STRINGIZE(VERSION_MINOR) "." STRINGIZE(VERSION_REVISION) "." STRINGIZE(VERSION_BUILD) #define PRODUCT_VERSION FILE_VERSION #define PRODUCT_VERSION_STRING FILE_VERSION_STRING @@ -29,18 +29,41 @@ const wchar_t* get_architecture_string(const version_architecture); inline std::wstring get_product_version(bool includeV = true) { - static std::wstring version = (includeV ? L"v" : L"") + std::to_wstring(VERSION_MAJOR) + - L"." + std::to_wstring(VERSION_MINOR) + - L"." + std::to_wstring(VERSION_REVISION); + std::wstring version = includeV ? L"v" : L""; + version += std::to_wstring(VERSION_MAJOR); + version += L"."; + version += std::to_wstring(VERSION_MINOR); + version += L"."; + version += std::to_wstring(VERSION_REVISION); + if constexpr (VERSION_BUILD != 0) + { + version += L"."; + version += std::to_wstring(VERSION_BUILD); + } return version; } inline std::wstring get_std_product_version(bool includeV = true) { - static std::wstring version = (includeV ? L"v" : L"") + std::to_wstring(VERSION_MAJOR) + - L"." + std::to_wstring(VERSION_MINOR) + - L"." + std::to_wstring(VERSION_REVISION) + L".0"; + std::wstring version = includeV ? L"v" : L""; + version += std::to_wstring(VERSION_MAJOR); + version += L"."; + version += std::to_wstring(VERSION_MINOR); + version += L"."; + version += std::to_wstring(VERSION_REVISION); + version += L"."; + version += std::to_wstring(VERSION_BUILD); return version; } + +inline std::wstring get_product_version_channel() +{ + return VERSION_CHANNEL; +} + +inline std::wstring get_product_version_source_commit() +{ + return VERSION_SOURCE_COMMIT; +} diff --git a/src/common/version/version.vcxproj b/src/common/version/version.vcxproj index 27d7666c29..718d167526 100644 --- a/src/common/version/version.vcxproj +++ b/src/common/version/version.vcxproj @@ -8,11 +8,23 @@ BeforeTargets="PrepareForBuild" Inputs="$(RepoRoot)src\Version.props" Outputs="$(MSBuildProjectDirectory)\Generated Files\version_gen.h"> + + 0 + $([System.String]::Copy('$(Version)').Split('.')[3]) + $(SourceCommit) + $(BUILD_SOURCEVERSION) + $(GITHUB_SHA) + unknown + private + + + + diff --git a/src/gpo/assets/PowerToys.admx b/src/gpo/assets/PowerToys.admx index eabd7cac97..bc71d9229f 100644 --- a/src/gpo/assets/PowerToys.admx +++ b/src/gpo/assets/PowerToys.admx @@ -1,11 +1,11 @@ - + - + @@ -30,6 +30,7 @@ + @@ -561,6 +562,16 @@ + + + + + + + + + + diff --git a/src/gpo/assets/en-US/PowerToys.adml b/src/gpo/assets/en-US/PowerToys.adml index 30442270f5..f7d8068c2e 100644 --- a/src/gpo/assets/en-US/PowerToys.adml +++ b/src/gpo/assets/en-US/PowerToys.adml @@ -1,7 +1,7 @@ - + PowerToys PowerToys @@ -37,6 +37,7 @@ PowerToys version 0.97.0 or later PowerToys version 0.98.0 or later PowerToys version 0.99.0 or later + PowerToys version 0.100.0 or later From PowerToys version 0.64.0 until PowerToys version 0.87.1 This policy configures the enabled state for all PowerToys utilities. @@ -78,6 +79,11 @@ If enabled, per-user installation is not allowed. If disabled or not configured, per-user installation is allowed. + This policy configures whether users can opt in to preview (prerelease) build updates. + +If you enable this policy, preview build updates are turned off: the per-user setting to include preview/prerelease updates is ignored and cannot be changed. Only stable updates are offered. + +If you disable or do not configure this policy, users can choose whether to receive preview build updates. Stable updates are unaffected either way. This policy configures whether or not the automatic download and installation of available updates is disabled. (On metered connections updates are never downloaded.) If enabled, automatic download and installation is disabled. @@ -289,6 +295,7 @@ If you don't configure this policy, the user will be able to control the setting Zoom It: Configure enabled state Disable per-user installation Disable automatic downloads + Disable preview build updates Do not show the release notes after updates Suspend Action Center notification for new updates Disable Action Center notification for new updates diff --git a/src/runner/Resources.resx b/src/runner/Resources.resx index c9652991f3..2cd071c4f0 100644 --- a/src/runner/Resources.resx +++ b/src/runner/Resources.resx @@ -153,6 +153,9 @@ An update to PowerToys is available. + + A PowerToys Preview update is available. + Update now diff --git a/src/runner/UpdateUtils.cpp b/src/runner/UpdateUtils.cpp index 214672cc9e..8bde2cd156 100644 --- a/src/runner/UpdateUtils.cpp +++ b/src/runner/UpdateUtils.cpp @@ -28,24 +28,54 @@ namespace // How many minor versions to suspend the toast notification (example: installed=0.60.0, suspend=2, next notification=0.63.*) // Attention: When changing this value please update the ADML file to. const int UPDATE_NOTIFICATION_TOAST_SUSPEND_MINOR_VERSION_COUNT = 2; + + // The per-user "include prerelease updates" opt-in, additionally gated by the DisablePreviewUpdates + // group policy: when that policy is Enabled, preview (prerelease) updates are forced off regardless + // of the user's setting. Stable updates are unaffected. + bool effective_include_prerelease_updates() + { + if (powertoys_gpo::getDisablePreviewUpdatesValue() == powertoys_gpo::gpo_rule_configured_enabled) + { + return false; + } + return get_general_settings().includePrereleaseUpdates; + } } using namespace notifications; using namespace updating; +std::wstring AvailableVersionToWstring(const new_version_download_info& info) +{ + auto result = info.version.toWstring(); + if (info.is_prerelease) + { + result += L"-preview"; + } + + return result; +} + std::wstring CurrentVersionToNextVersion(const new_version_download_info& info) { auto result = VersionHelper{ VERSION_MAJOR, VERSION_MINOR, VERSION_REVISION }.toWstring(); result += L" \u2192 "; // Right arrow - result += info.version.toWstring(); + result += AvailableVersionToWstring(info); return result; } +std::wstring UpdateAvailableMessage(const new_version_download_info& info) +{ + return info.is_prerelease ? + GET_RESOURCE_STRING(IDS_GITHUB_NEW_PREVIEW_VERSION_AVAILABLE) : + GET_RESOURCE_STRING(IDS_GITHUB_NEW_VERSION_AVAILABLE); +} + void ShowNewVersionAvailable(const new_version_download_info& info) { remove_toasts_by_tag(UPDATING_PROCESS_TOAST_TAG); toast_params toast_params{ UPDATING_PROCESS_TOAST_TAG, false }; - std::wstring contents = GET_RESOURCE_STRING(IDS_GITHUB_NEW_VERSION_AVAILABLE); + std::wstring contents = UpdateAvailableMessage(info); contents += L'\n'; contents += CurrentVersionToNextVersion(info); @@ -60,7 +90,7 @@ void ShowNewVersionAvailable(const new_version_download_info& info) L"powertoys://open_overview/"); } -void ShowOpenSettingsForUpdate() +void ShowOpenSettingsForUpdate(const new_version_download_info& info) { remove_toasts_by_tag(UPDATING_PROCESS_TOAST_TAG); @@ -70,7 +100,10 @@ void ShowOpenSettingsForUpdate() link_button{ GET_RESOURCE_STRING(IDS_GITHUB_NEW_VERSION_MORE_INFO), L"powertoys://open_overview/" }, }; - show_toast_with_activations(GET_RESOURCE_STRING(IDS_GITHUB_NEW_VERSION_AVAILABLE), + auto contents = UpdateAvailableMessage(info); + contents += L'\n'; + contents += AvailableVersionToWstring(info); + show_toast_with_activations(std::move(contents), GET_RESOURCE_STRING(IDS_TOAST_TITLE), {}, std::move(actions), @@ -130,12 +163,14 @@ void ProcessNewVersionInfo(const github_version_info& version_info, state.state = UpdateState::upToDate; state.releasePageUrl = {}; state.downloadedInstallerFilename = {}; + state.isPrerelease = false; Logger::trace(L"Version is up to date"); dispatch_run_on_main_ui_thread([](PVOID) { set_tray_icon_update_available(false); }, nullptr); return; } const auto new_version_info = std::get(version_info); state.releasePageUrl = new_version_info.release_page_uri.ToString().c_str(); + state.isPrerelease = new_version_info.is_prerelease; Logger::trace(L"Discovered new version {}", new_version_info.version.toWstring()); const bool already_downloaded = state.state == UpdateState::readyToInstall && state.downloadedInstallerFilename == new_version_info.installer_filename; @@ -203,7 +238,7 @@ void ProcessNewVersionInfo(const github_version_info& version_info, dispatch_run_on_main_ui_thread([](PVOID) { set_tray_icon_update_available(true); }, nullptr); if (show_notifications) { - ShowOpenSettingsForUpdate(); + ShowOpenSettingsForUpdate(new_version_info); } } } @@ -237,7 +272,7 @@ void PeriodicUpdateWorker() bool version_info_obtained = false; try { - const auto new_version_info = std::move(get_github_version_info_async()).get(); + const auto new_version_info = std::move(get_github_version_info_async(effective_include_prerelease_updates())).get(); if (new_version_info.has_value()) { version_info_obtained = true; @@ -277,7 +312,7 @@ void CheckForUpdatesCallback() auto state = UpdateState::read(); try { - auto new_version_info = std::move(get_github_version_info_async()).get(); + auto new_version_info = std::move(get_github_version_info_async(effective_include_prerelease_updates())).get(); if (!new_version_info) { // We couldn't get a new version from github for some reason, log error diff --git a/src/runner/general_settings.cpp b/src/runner/general_settings.cpp index 6225ed8f2a..58a65198da 100644 --- a/src/runner/general_settings.cpp +++ b/src/runner/general_settings.cpp @@ -71,6 +71,7 @@ static bool show_theme_adaptive_tray_icon = false; static bool run_as_elevated = false; static bool show_new_updates_toast_notification = true; static bool download_updates_automatically = true; +static bool include_prerelease_updates = false; static bool show_whats_new_after_updates = true; static bool enable_experimentation = true; static bool enable_warnings_elevated_apps = true; @@ -105,6 +106,7 @@ json::JsonObject GeneralSettings::to_json() result.SetNamedValue(L"run_elevated", json::value(isRunElevated)); result.SetNamedValue(L"show_new_updates_toast_notification", json::value(showNewUpdatesToastNotification)); result.SetNamedValue(L"download_updates_automatically", json::value(downloadUpdatesAutomatically)); + result.SetNamedValue(L"include_prerelease_updates", json::value(includePrereleaseUpdates)); result.SetNamedValue(L"show_whats_new_after_updates", json::value(showWhatsNewAfterUpdates)); result.SetNamedValue(L"enable_experimentation", json::value(enableExperimentation)); result.SetNamedValue(L"dashboard_sort_order", json::value(static_cast(dashboardSortOrder))); @@ -133,6 +135,7 @@ json::JsonObject load_general_settings() run_as_elevated = loaded.GetNamedBoolean(L"run_elevated", false); show_new_updates_toast_notification = loaded.GetNamedBoolean(L"show_new_updates_toast_notification", true); download_updates_automatically = loaded.GetNamedBoolean(L"download_updates_automatically", true) && check_user_is_admin(); + include_prerelease_updates = loaded.GetNamedBoolean(L"include_prerelease_updates", false); show_whats_new_after_updates = loaded.GetNamedBoolean(L"show_whats_new_after_updates", true); enable_experimentation = loaded.GetNamedBoolean(L"enable_experimentation", true); enable_warnings_elevated_apps = loaded.GetNamedBoolean(L"enable_warnings_elevated_apps", true); @@ -172,6 +175,7 @@ GeneralSettings get_general_settings() .quickAccessShortcut = quick_access_shortcut, .showNewUpdatesToastNotification = show_new_updates_toast_notification, .downloadUpdatesAutomatically = download_updates_automatically && is_user_admin, + .includePrereleaseUpdates = include_prerelease_updates, .showWhatsNewAfterUpdates = show_whats_new_after_updates, .enableExperimentation = enable_experimentation, .dashboardSortOrder = dashboard_sort_order, @@ -335,6 +339,7 @@ void apply_general_settings(const json::JsonObject& general_configs, bool save) show_new_updates_toast_notification = general_configs.GetNamedBoolean(L"show_new_updates_toast_notification", true); download_updates_automatically = general_configs.GetNamedBoolean(L"download_updates_automatically", true); + include_prerelease_updates = general_configs.GetNamedBoolean(L"include_prerelease_updates", false); show_whats_new_after_updates = general_configs.GetNamedBoolean(L"show_whats_new_after_updates", true); enable_experimentation = general_configs.GetNamedBoolean(L"enable_experimentation", true); @@ -585,4 +590,3 @@ void start_enabled_powertoys() } } - diff --git a/src/runner/general_settings.h b/src/runner/general_settings.h index 487e3216da..0ee972cb4b 100644 --- a/src/runner/general_settings.h +++ b/src/runner/general_settings.h @@ -24,6 +24,7 @@ struct GeneralSettings PowerToysSettings::HotkeyObject quickAccessShortcut; bool showNewUpdatesToastNotification; bool downloadUpdatesAutomatically; + bool includePrereleaseUpdates; bool showWhatsNewAfterUpdates; bool enableExperimentation; DashboardSortOrder dashboardSortOrder; diff --git a/src/runner/settings_window.cpp b/src/runner/settings_window.cpp index 8c95b0f99f..f6316a7aff 100644 --- a/src/runner/settings_window.cpp +++ b/src/runner/settings_window.cpp @@ -111,6 +111,7 @@ std::optional dispatch_json_action_to_module(const json::JsonObjec } else if (action == L"check_for_updates") { + apply_general_settings(value); bool expected_isUpdateCheckThreadRunning = false; if (isUpdateCheckThreadRunning.compare_exchange_strong(expected_isUpdateCheckThreadRunning, true)) { diff --git a/src/runner/trace.cpp b/src/runner/trace.cpp index 6fb2f89ba8..8168747992 100644 --- a/src/runner/trace.cpp +++ b/src/runner/trace.cpp @@ -49,6 +49,7 @@ void Trace::SettingsChanged(const GeneralSettings& settings) TraceLoggingWideString(enabledModules.c_str(), "ModulesEnabled"), TraceLoggingBoolean(settings.isRunElevated, "AlwaysRunElevated"), TraceLoggingBoolean(settings.downloadUpdatesAutomatically, "DownloadUpdatesAutomatically"), + TraceLoggingBoolean(settings.includePrereleaseUpdates, "IncludePrereleaseUpdates"), TraceLoggingBoolean(settings.enableExperimentation, "EnableExperimentation"), TraceLoggingWideString(settings.theme.c_str(), "Theme"), ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), diff --git a/src/settings-ui/Settings.UI.Library/GeneralSettings.cs b/src/settings-ui/Settings.UI.Library/GeneralSettings.cs index 727384b5e6..f3bb727cf8 100644 --- a/src/settings-ui/Settings.UI.Library/GeneralSettings.cs +++ b/src/settings-ui/Settings.UI.Library/GeneralSettings.cs @@ -89,6 +89,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("download_updates_automatically")] public bool AutoDownloadUpdates { get; set; } + [JsonPropertyName("include_prerelease_updates")] + public bool IncludePrereleaseUpdates { get; set; } + [JsonPropertyName("show_whats_new_after_updates")] public bool ShowWhatsNewAfterUpdates { get; set; } @@ -112,6 +115,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library IsElevated = false; ShowNewUpdatesToastNotification = true; AutoDownloadUpdates = true; + IncludePrereleaseUpdates = false; EnableExperimentation = true; DashboardSortOrder = DashboardSortOrder.Alphabetical; Theme = "system"; diff --git a/src/settings-ui/Settings.UI.Library/UpdatingSettings.cs b/src/settings-ui/Settings.UI.Library/UpdatingSettings.cs index 4a6065efae..1a28799d68 100644 --- a/src/settings-ui/Settings.UI.Library/UpdatingSettings.cs +++ b/src/settings-ui/Settings.UI.Library/UpdatingSettings.cs @@ -38,6 +38,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("downloadedInstallerFilename")] public string DownloadedInstallerFilename { get; set; } + [JsonPropertyName("isPrerelease")] + public bool IsPrerelease { get; set; } + // Non-localizable strings: Files public const string SettingsFilePath = "\\Microsoft\\PowerToys\\"; public const string SettingsFile = "UpdateState.json"; diff --git a/src/settings-ui/Settings.UI.UnitTests/ScoobeReleaseTests.cs b/src/settings-ui/Settings.UI.UnitTests/ScoobeReleaseTests.cs new file mode 100644 index 0000000000..70c05271a5 --- /dev/null +++ b/src/settings-ui/Settings.UI.UnitTests/ScoobeReleaseTests.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +using Microsoft.PowerToys.Settings.UI; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Settings.UI.UnitTests +{ + [TestClass] + public class ScoobeReleaseTests + { + [TestMethod] + public void CreateReleaseGroupsHidesPrereleasesByDefault() + { + var groups = ScoobeWindow.CreateReleaseGroups(CreateReleases(), false); + + Assert.AreEqual(2, groups.Count); + Assert.IsTrue(groups.SelectMany(group => group).All(release => !release.IsPrerelease)); + } + + [TestMethod] + public void CreateReleaseGroupsSeparatesPrereleasesWhenEnabled() + { + var groups = ScoobeWindow.CreateReleaseGroups(CreateReleases(), true); + + Assert.AreEqual(3, groups.Count); + Assert.IsTrue(groups[0].All(release => release.IsPrerelease)); + Assert.IsTrue(groups.Skip(1).SelectMany(group => group).All(release => !release.IsPrerelease)); + } + + [TestMethod] + public void UpdatingSettingsReadsPrereleaseState() + { + var settings = JsonSerializer.Deserialize("""{"state":2,"isPrerelease":true}"""); + + Assert.IsNotNull(settings); + Assert.IsTrue(settings.IsPrerelease); + } + + private static IList CreateReleases() + { + return + [ + new PowerToysReleaseInfo + { + TagName = "v0.100.2607.27001-preview", + IsPrerelease = true, + PublishedDate = new DateTimeOffset(2026, 7, 27, 0, 0, 0, TimeSpan.Zero), + }, + new PowerToysReleaseInfo + { + TagName = "v0.100.2", + PublishedDate = new DateTimeOffset(2026, 6, 26, 0, 0, 0, TimeSpan.Zero), + }, + new PowerToysReleaseInfo + { + TagName = "v0.99.1", + PublishedDate = new DateTimeOffset(2026, 4, 15, 0, 0, 0, TimeSpan.Zero), + }, + ]; + } + } +} diff --git a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/General.cs b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/General.cs index 1d958b43fb..d71d9e4610 100644 --- a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/General.cs +++ b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/General.cs @@ -84,6 +84,7 @@ namespace ViewModelTests // Verify that the old settings persisted Assert.AreEqual(originalGeneralSettings.AutoDownloadUpdates, viewModel.AutoDownloadUpdates); + Assert.AreEqual(originalGeneralSettings.IncludePrereleaseUpdates, viewModel.IncludePrereleaseUpdates); Assert.AreEqual(originalGeneralSettings.PowertoysVersion, viewModel.PowerToysVersion); Assert.AreEqual(originalGeneralSettings.RunElevated, viewModel.RunElevated); Assert.AreEqual(originalGeneralSettings.Startup, viewModel.Startup); @@ -93,6 +94,67 @@ namespace ViewModelTests BackCompatTestProperties.VerifyGeneralSettingsIOProviderWasRead(fileMock, expectedCallCount); } + [TestMethod] + public void IncludePrereleaseUpdatesShouldSendUpdatedSettingWhenSuccessful() + { + bool sawExpectedIpcPayload = false; + bool sawExpectedUpdateCheckPayload = false; + Func sendMockIPCConfigMSG = msg => + { + if (string.IsNullOrWhiteSpace(msg)) + { + return 0; + } + + OutGoingGeneralSettings snd = JsonSerializer.Deserialize(msg); + if (snd?.GeneralSettings is null) + { + return 0; + } + + Assert.IsTrue(snd.GeneralSettings.IncludePrereleaseUpdates); + sawExpectedIpcPayload = true; + return 0; + }; + + Func sendRestartAdminIPCMessage = msg => { return 0; }; + Func sendCheckForUpdatesIPCMessage = msg => + { + if (string.IsNullOrWhiteSpace(msg)) + { + return 0; + } + + GeneralSettingsCustomAction action = JsonSerializer.Deserialize(msg); + if (action?.GeneralSettingsAction?.GeneralSettings is null) + { + return 0; + } + + Assert.IsTrue(action.GeneralSettingsAction.GeneralSettings.IncludePrereleaseUpdates); + Assert.AreEqual("check_for_updates", action.GeneralSettingsAction.GeneralSettings.CustomActionName); + sawExpectedUpdateCheckPayload = true; + return 0; + }; + GeneralViewModel viewModel = new TestGeneralViewModel( + settingsRepository: SettingsRepository.GetInstance(mockGeneralSettingsUtils.Object), + "GeneralSettings_RunningAsAdminText", + "GeneralSettings_RunningAsUserText", + false, + false, + sendMockIPCConfigMSG, + sendRestartAdminIPCMessage, + sendCheckForUpdatesIPCMessage, + GeneralSettingsFileName); + + Assert.IsFalse(viewModel.IncludePrereleaseUpdates); + + viewModel.IncludePrereleaseUpdates = true; + + Assert.IsTrue(sawExpectedIpcPayload); + Assert.IsTrue(sawExpectedUpdateCheckPayload); + } + [TestMethod] public void IsElevatedShouldUpdateRunasAdminStatusAttrsWhenSuccessful() { diff --git a/src/settings-ui/Settings.UI/Helpers/PowerToysReleaseInfo.cs b/src/settings-ui/Settings.UI/Helpers/PowerToysReleaseInfo.cs index 5805eac47a..329cda4be7 100644 --- a/src/settings-ui/Settings.UI/Helpers/PowerToysReleaseInfo.cs +++ b/src/settings-ui/Settings.UI/Helpers/PowerToysReleaseInfo.cs @@ -25,5 +25,8 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers [JsonPropertyName("body")] public string ReleaseNotes { get; set; } + + [JsonPropertyName("prerelease")] + public bool IsPrerelease { get; set; } } } diff --git a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj index 87bcc9ec7b..381e7968e1 100644 --- a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj +++ b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj @@ -108,6 +108,10 @@ + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseGroupViewModel.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseGroupViewModel.cs index e384ea8a13..b28010d134 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseGroupViewModel.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseGroupViewModel.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; using Microsoft.PowerToys.Settings.UI.Helpers; @@ -30,6 +31,8 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views /// public string DateText { get; } + public bool IsPreview { get; } + public ScoobeReleaseGroupViewModel(IList releases) { Releases = releases ?? throw new ArgumentNullException(nameof(releases)); @@ -37,8 +40,11 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views if (releases.Count > 0) { var latestRelease = releases[0]; + IsPreview = releases.All(release => release.IsPrerelease); VersionText = GetVersionFromRelease(latestRelease); - DateText = latestRelease.PublishedDate.ToString("MMMM yyyy", CultureInfo.CurrentCulture); + DateText = IsPreview ? + ResourceLoaderInstance.ResourceLoader.GetString("ScoobeReleaseGroup_Preview") : + latestRelease.PublishedDate.ToString("MMMM yyyy", CultureInfo.CurrentCulture); } else { diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseNotesPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseNotesPage.xaml.cs index d1e5007a71..4ff79d9309 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseNotesPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseNotesPage.xaml.cs @@ -118,6 +118,12 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views isFirst = false; + if (release.IsPrerelease) + { + releaseNotesHtmlBuilder.AppendLine(CultureInfo.InvariantCulture, $"**{ResourceLoaderInstance.ResourceLoader.GetString("ScoobeReleaseNotes_PreviewBadge")}**"); + releaseNotesHtmlBuilder.AppendLine(); + } + var releaseUrl = string.Format(CultureInfo.InvariantCulture, GitHubReleaseLinkTemplate, release.TagName); releaseNotesHtmlBuilder.AppendLine(CultureInfo.InvariantCulture, $"# {release.Name}"); string formattedDate = release.PublishedDate.ToString($"{CultureInfo.CurrentCulture.DateTimeFormat.MonthDayPattern}, yyyy", CultureInfo.CurrentCulture); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/ScoobeWindow.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/ScoobeWindow.xaml.cs index 67001d2c25..b84d7b832d 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/ScoobeWindow.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/ScoobeWindow.xaml.cs @@ -11,6 +11,7 @@ using System.Text.Json; using System.Threading.Tasks; using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.OOBE.Views; using Microsoft.PowerToys.Settings.UI.SerializationContext; using Microsoft.UI.Xaml; @@ -110,7 +111,7 @@ namespace Microsoft.PowerToys.Settings.UI try { var releases = await FetchReleasesFromGitHubAsync(); - ReleaseGroups = GroupReleasesByMajorMinor(releases); + ReleaseGroups = CreateReleaseGroups(releases, ShouldShowPrereleases()); PopulateNavigationItems(); } catch (Exception ex) @@ -137,7 +138,7 @@ namespace Microsoft.PowerToys.Settings.UI using var httpClient = new HttpClient(proxyClientHandler); httpClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "PowerToys"); - string json = await httpClient.GetStringAsync("https://api.github.com/repos/microsoft/PowerToys/releases?per_page=20"); + string json = await httpClient.GetStringAsync("https://api.github.com/repos/microsoft/PowerToys/releases?per_page=100"); var allReleases = JsonSerializer.Deserialize>(json, SourceGenerationContextContext.Default.IListPowerToysReleaseInfo); if (allReleases is null || allReleases.Count == 0) @@ -150,12 +151,45 @@ namespace Microsoft.PowerToys.Settings.UI .ToList(); } - private static IList> GroupReleasesByMajorMinor(IList releases) + internal static IList> CreateReleaseGroups(IList releases, bool showPrereleases) { - return releases + var groups = new List>(); + if (showPrereleases) + { + var previewReleases = releases + .Where(r => r.IsPrerelease) + .OrderByDescending(r => r.PublishedDate) + .Take(10) + .ToList(); + if (previewReleases.Count > 0) + { + groups.Add(previewReleases); + } + } + + var stableGroups = releases + .Where(r => !r.IsPrerelease) + .OrderByDescending(r => r.PublishedDate) + .Take(20) .GroupBy(GetMajorMinorVersion) .Select(g => g.OrderByDescending(r => r.PublishedDate).ToList() as IList) .ToList(); + foreach (var stableGroup in stableGroups) + { + groups.Add(stableGroup); + } + + return groups; + } + + private static bool ShouldShowPrereleases() + { + var generalSettings = SettingsRepository.GetInstance(SettingsUtils.Default).SettingsConfig; + bool isPreviewBuild = string.Equals( + global::PowerToys.Interop.CommonManaged.GetProductVersionChannel(), + "preview", + StringComparison.OrdinalIgnoreCase); + return generalSettings.IncludePrereleaseUpdates || isPreviewBuild; } private static string GetMajorMinorVersion(PowerToysReleaseInfo release) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml index 10c1d64a27..6ba562a4fb 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml @@ -22,7 +22,22 @@ - + + + + + + + + + @@ -77,6 +92,9 @@ Visibility="{x:Bind ViewModel.IsAdmin, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"> + + + @@ -104,7 +122,7 @@ + + +