feat(release): automate draft preview release preparation (#49797)

## Summary of the Pull Request

Adds a `Prepare Preview Release` custom agent that autonomously turns a
successful PowerToys Azure DevOps release-candidate build into a
complete GitHub draft prerelease for final human review.

The implementation extends the existing `release-note-generation` skill
instead of duplicating it. It adds exact-build metadata resolution,
published-release baseline selection, semantic PR deltas across `main`
and `stable`, release asset validation, idempotent draft-only release
updates, and final draft verification.

## PR Checklist

- [x] **Communication:** The autonomous preview-release design was
reviewed and approved before implementation
- [x] **Tests:** Added/updated and all pass
- [x] **Localization:** N/A; no end-user-facing strings were added
- [x] **Dev docs:** Added preview scenario, delta, draft safety, and
reporting references

## Detailed Description of the Pull Request / Additional comments

- Adds `.github/agents/prepare-preview-release.agent.md` with a
no-mid-run-decision workflow and a strict prohibition on publishing
releases.
- Extends `.github/skills/release-note-generation/SKILL.md` with
stable/preview scenario routing while preserving the existing
stable-release workflow.
- Adds canonical scripts under
`.github/skills/release-note-generation/scripts/` to:
  - Resolve and validate ADO build metadata.
- Select the latest published stable or preview baseline before build
queue time.
- Calculate same-lineage or branch-transition PR deltas using PR
numbers, cherry-pick provenance, and patch-ID equivalence.
  - Collect normalized PR metadata and create `release-manifest.json`.
- Download and validate installers, symbols, and GPO assets, including
hashes, signatures, and ZIP contents.
- Create or update draft prereleases while preserving human text outside
managed markers.
- Verify draft flags, immutable target commit, body markers, and
uploaded assets.
- Updates `.pipelines/resolveBuildMetadata.ps1` and
`.pipelines/v2/release.yml` with explicit `auto`, `preview-release`, and
`stable-release` intent handling so preview candidates can be built from
either `main` or `stable`.
- Adds `.pipelines/writeReleaseMetadata.ps1` so each signed build
artifact records its resolved version, channel, intent, source branch,
and immutable source commit.
- Keeps release publication outside the agent: the automation can only
create or update a draft prerelease.

## Validation Steps Performed

- `Invoke-Pester` for:
  - `.pipelines/tests/resolveBuildMetadata.Tests.ps1`
  - `.pipelines/tests/writeReleaseMetadata.Tests.ps1`
-
`.github/skills/release-note-generation/tests/preview-release.Tests.ps1`
- 37 tests passed, covering stable-branch preview intent, metadata
contracts, baseline selection, same-lineage and branch-transition
deltas, patch-ID equivalence, managed-body preservation, and
published-release refusal.
- Parsed all added or modified PowerShell scripts with the PowerShell
AST parser.
- Parsed the modified pipeline YAML files with `ConvertFrom-Yaml`.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8e04a72e-3b0f-4ac4-8156-d04ea9b8bb85
Copilot-Session: e9f79ac2-9a7b-4083-834c-0d87e8c83bfd
Copilot-Session: 1ecea747-b313-49a1-9969-543c01ba1be8
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7b3fb20d-6e9d-4fef-a5cd-f8921d28c220
This commit is contained in:
Boliang Zhang
2026-08-17 14:47:18 +08:00
committed by Boliang Zhang (from Dev Box)
parent abf346a864
commit d17d4e94e7
26 changed files with 3312 additions and 52 deletions

View File

@@ -0,0 +1,80 @@
---
description: 'Prepares a complete PowerToys GitHub draft preview release from an Azure DevOps release-candidate build'
name: 'Prepare Preview Release'
tools: ['read', 'edit', 'search', 'execute', 'github/*', 'agent']
argument-hint: 'Azure DevOps build URL or build ID'
infer: false
---
# Prepare Preview Release Agent
You are the PowerToys preview-release preparation agent. Convert one Azure DevOps release-candidate build into a complete, verified GitHub draft prerelease for final human review.
## Required input
Accept exactly one build URL or numeric build ID from the `microsoft` organization, `Dart` project, release definition `76541`.
Do not request a version, branch, baseline tag, milestone, or release-note range. Derive them from the build and published-release metadata.
## Supported preview build sources
During release preparation, preview builds may be sourced from either `main` or `stable`. Both are official supported patterns. A successful build from trusted release definition `76541` on either branch is eligible regardless of its resolved intent, channel, or `shouldPublishPreview` value.
Preserve the candidate build's branch, intent, channel, and publication flag as immutable audit evidence, but do not use those metadata fields as eligibility gates. The preview-release request determines the GitHub draft type.
Reject builds from other branches, failed or incomplete builds, non-release definitions, unresolved versions or commits, and missing or invalid assets.
## Core directive
Follow the [preview release scenario](../skills/release-note-generation/references/scenarios/preview-release.md) in the existing `release-note-generation` skill. That skill is the source of truth for PR metadata, attribution, grouping, release-note formatting, asset naming, semantic delta calculation, and draft safety.
Run autonomously from input validation through either:
1. A complete verified GitHub draft prerelease and final review report; or
2. A terminal failure report identifying the safety gate that stopped the run.
Never ask for a decision after processing begins.
## Non-negotiable safety rules
- Never publish a release.
- Never remove draft status.
- Never create a non-prerelease.
- Never retarget the release to a movable branch.
- Never modify product source files.
- Never modify PR milestones or labels.
- Never continue after a build, identity, asset, signature, hash, or published-tag conflict.
- Never upload `release-manifest.json`; retain it only in the local audit package.
- Preserve human release-body edits outside the managed markers.
- Use canonical skill scripts instead of ad hoc GitHub or ADO write operations.
## Autonomous decisions
Continue without asking when labels or summaries are ambiguous:
- Put missing labels under `General`.
- Use conservative PR-title wording for low-confidence summaries.
- Include unattributed commits under `Changes needing final review`.
- Include removed PRs under `Differences from the previous preview`.
- Update an existing draft idempotently.
Stop without asking when:
- The build is incomplete, failed, or not definition `76541`.
- The candidate is not a supported release-preparation build from `main` or `stable`.
- Version or source commit cannot be resolved uniquely.
- The target is older than the selected same-lineage baseline.
- Required assets are missing, unsigned, corrupt, or have mismatched hashes.
- A published release already owns the target tag.
## Output contract
Write all local artifacts under:
```text
Generated Files/ReleaseNotes/preview-<buildId>/
```
The directory must contain the build context, baseline, delta JSON, normalized PR data, release notes, local-only release manifest, asset manifest, hashes, and final review report described by the skill.
Finish by returning the draft URL and the concise contents of `final-review.md`. Human involvement begins only after the complete draft is ready.

View File

@@ -1,12 +1,12 @@
---
name: release-note-generation
description: Toolkit for generating PowerToys release notes from GitHub milestone PRs or commit ranges. Use when asked to create release notes, summarize milestone PRs, generate changelog, prepare release documentation, generate PR review summaries locally for release notes, update README for a new release, manage PR milestones, collect PRs between commits/tags, or prepare release assets (download installers and compute installer hashes).
description: Toolkit for generating PowerToys stable or preview release notes from GitHub milestones, commit ranges, or Azure DevOps release-candidate builds. Use when asked to create release notes, summarize milestone PRs, generate changelog, prepare a draft preview release, calculate PR deltas across main and stable, update release documentation, manage PR milestones, or prepare and validate release assets.
license: Complete terms in LICENSE.txt
---
# Release Note Generation Skill
Generate professional release notes for PowerToys milestones by collecting merged PRs, summarizing each PR with the local CLI agent, grouping by label, and producing user-facing summaries.
Generate professional PowerToys release notes by collecting merged PRs, summarizing each PR with the local CLI agent, grouping by label, and producing user-facing summaries. Stable and preview releases share the same PR metadata, attribution, grouping, and formatting rules.
## Output Directory
@@ -22,6 +22,25 @@ Generated Files/ReleaseNotes/
└── v{VERSION}-release-notes.md # Final consolidated release notes
```
Preview-release runs use an isolated subdirectory:
```text
Generated Files/ReleaseNotes/preview-<buildId>/
├── release-context.json
├── delta-commits.json
├── delta-prs.json
├── removed-prs.json
├── unattributed-commits.json
├── MemberList.md
├── milestone_prs.json
├── sorted_prs.csv
├── release-notes.md
├── hashes.md
├── release-manifest.json # Local audit artifact; never uploaded
├── assets-manifest.json # Local asset inventory; never uploaded
└── final-review.md
```
## When to Use This Skill
- Generate release notes for a milestone
@@ -31,21 +50,33 @@ Generated Files/ReleaseNotes/
- Collect PRs between two commits/tags
- Update README.md for a new version
- Prepare GitHub release assets (download installers/symbols + compute hashes)
- Prepare a complete draft preview release from an ADO build URL or build ID
- Compare preview contents across `main` and `stable` branch transitions
## Prerequisites
- **GitHub CLI (`gh`) installed and authenticated** — The collection script uses `gh pr view` and `gh api graphql` to fetch PR metadata and co-author information. Run `gh auth status` to verify; if not logged in, run `gh auth login` first. See [Step 1.0.0](./references/step1-collection.md) for details.
- MCP Server: github-mcp-server installed (used to fetch PR diffs/files for the local-agent review step)
- For [prepare-release-assets.ps1](./scripts/prepare-release-assets.ps1) only: **Azure CLI** authenticated against the Microsoft tenant (`az login`) with the `azure-devops` extension; access to the `microsoft/Dart` ADO project
- For preview releases and [prepare-release-assets.ps1](./scripts/prepare-release-assets.ps1): **Azure CLI** authenticated against the Microsoft tenant (`az login`) with the `azure-devops` extension; access to the `microsoft/Dart` ADO project
## Required Variables
⚠️ **Before starting**, confirm `{{ReleaseVersion}}` with the user. If not provided, **ASK**: "What release version are we generating notes for? (e.g., 0.98)"
For a stable release, confirm `{{ReleaseVersion}}` with the user before starting.
For a preview release, do not request a version: derive it from the candidate ADO build.
| Variable | Description | Example |
|----------|-------------|---------|
| `{{ReleaseVersion}}` | Target release version | `0.98` |
Preview mode instead requires one ADO build URL or numeric build ID. It derives the version, source commit, branch, and previous release without asking the user.
## Scenario routing
Read [the scenario index](./references/scenarios/index.md), then follow only the selected scenario:
- [Stable release](./references/scenarios/stable-release.md) for milestone- or version-based release notes.
- [Preview release](./references/scenarios/preview-release.md) for an autonomous ADO-build-to-draft workflow.
## Workflow Overview
```
@@ -116,6 +147,13 @@ Do not read all steps at once—only read the step you are executing.
| [collect-or-apply-milestones.ps1](./scripts/collect-or-apply-milestones.ps1) | Assign milestones |
| [diff_prs.ps1](./scripts/diff_prs.ps1) | Incremental PR diff |
| [prepare-release-assets.ps1](./scripts/prepare-release-assets.ps1) | Download installers + symbols from an ADO build, compute SHA256, emit the "Installer Hashes" markdown table for the GitHub release page |
| [get-release-build-metadata.ps1](./scripts/get-release-build-metadata.ps1) | Resolve and validate the candidate build identity, version, channel, intent, and source commit |
| [get-previous-published-release.ps1](./scripts/get-previous-published-release.ps1) | Select the latest published stable or preview release that predates the candidate queue time |
| [get-preview-release-delta.ps1](./scripts/get-preview-release-delta.ps1) | Calculate semantic added/removed PRs between exact release commits |
| [collect-pr-metadata.ps1](./scripts/collect-pr-metadata.ps1) | Normalize GitHub metadata for an explicit set of PR numbers |
| [new-preview-release-manifest.ps1](./scripts/new-preview-release-manifest.ps1) | Create the auditable build, baseline, and semantic-delta release manifest |
| [upsert-draft-preview-release.ps1](./scripts/upsert-draft-preview-release.ps1) | Create or update a draft prerelease without exposing a publish operation |
| [verify-draft-preview-release.ps1](./scripts/verify-draft-preview-release.ps1) | Verify draft flags, target commit, managed body, and uploaded assets |
## References
@@ -125,6 +163,9 @@ Do not read all steps at once—only read the step you are executing.
## Conventions
- **Terminal usage**: Disabled by default; only run scripts when user explicitly requests
- **Preview automation**: An explicit request to prepare a preview release, or invocation by the Prepare Preview Release agent, authorizes the canonical preview scripts
- **Preview manifests**: Keep `release-manifest.json` and `assets-manifest.json` in the local audit package; do not upload either file as a GitHub release asset
- **Preview note layout**: Place the `Installer Hashes` section immediately after the title and short public introduction, before `Highlights` and all change sections
- **Batch generation**: Generate ALL grouped_md files in one pass, then human reviews
- **PR order**: Preserve order from `sorted_prs.csv` in all outputs
- **Label filtering**: Keeps `Product-*`, `Area-*`, `GitHub*`, `*Plugin`, `Issue-*`
@@ -138,3 +179,5 @@ Do not read all steps at once—only read the step you are executing.
| Empty `CopilotSummary` for many PRs | Run Step 3.1 (local-agent summaries). Do **not** use `mcp_github_request_copilot_review` from a CLI/coding agent — the GitHub API rejects bot-initiated review requests, so the column will stay empty. |
| Many unlabeled PRs | Return to labeling step before grouping |
| `prepare-release-assets.ps1` fails with "Failed to acquire ADO access token" | Run `az login` and ensure you have access to the `microsoft/Dart` ADO project |
| Candidate has no `release-metadata.json` | The metadata resolver uses pipeline-log fallback; ambiguous or conflicting values stop the run |
| First preview after switching branches has unexpected changes | Review `removed-prs.json` and `unattributed-commits.json`; see [preview delta resolution](./references/preview-delta-resolution.md) |

View File

@@ -0,0 +1,34 @@
# Preview delta resolution
Preview notes describe the target candidate relative to the latest published stable or preview release that predates the candidate queue time.
## Same lineage
When previous commit `P` is an ancestor of target commit `T`, use `P..T`. All represented PRs are added and none are removed.
If `T` is an ancestor of `P`, stop: the candidate is an automatic rollback relative to an already published release.
## Branch transition
When `P` and `T` diverge:
```text
M = merge-base(P, T)
Previous side = M..P
Target side = M..T
Added = Target identities - Previous identities
Removed = Previous identities - Target identities
```
Resolve commit identity in this order:
1. Squash subject ending in `(#<number>)`.
2. Merge subject containing `Merge pull request #<number>`.
3. GitHub-associated PR for the commit.
4. `cherry picked from commit <sha>` and the source commit's PR.
5. Stable patch ID shared across the two sides.
6. Unattributed commit identity.
PR number is the semantic identity. Equal PR numbers cancel even when cherry-picking changed the SHA.
Do not invent attribution for aggregate promotion commits. If a promotion manifest is unavailable, preserve unresolved commits in `unattributed-commits.json` and surface them in the final review section.

View File

@@ -0,0 +1,39 @@
# Preview draft safety
The preview workflow may create or update GitHub drafts only.
## Required identity
```text
Tag: v<resolved version>
Title: Preview v<resolved version>
Target: exact ADO source commit
Draft: true
Prerelease: true
```
Never use a branch name as the release target and never expose a publish parameter.
## Managed body
Wrap generated release content with:
```markdown
<!-- BEGIN POWERTOYS PREVIEW AGENT -->
...
<!-- END POWERTOYS PREVIEW AGENT -->
```
On rerun, replace only this region. Preserve human-authored text before and after it.
## Idempotency
- Create a draft when the tag is unused.
- Update an existing draft for the same tag.
- Stop if a published release owns the tag.
- Replace only expected generated assets.
- Keep `release-manifest.json` and `assets-manifest.json` local and remove any stale uploaded copies on rerun.
- Validate all local files before creating the draft.
- After every write, assert `draft=true` and `prerelease=true`.
If an upload or final verification fails, leave the release as a draft and report the incomplete state. A rerun must be able to replace missing or mismatched assets.

View File

@@ -0,0 +1,18 @@
# Preview release reporting
Write `final-review.md` and return the same key information to the user:
- Draft release URL, or explicit `Not created (dry run)` status for a local-only run.
- ADO build URL and build ID.
- Version, source branch, exact source commit, intent, and channel.
- Previous release tag and source commit.
- Delta mode and merge base when applicable.
- Added PR count and list.
- Removed PR count and list.
- Unattributed commit count and list.
- Uploaded asset inventory.
- Installer hash and signature results.
- Low-confidence note sections.
- Final human publication checklist.
Use explicit PASS, WARNING, or FAILURE wording. A successful live run ends with a complete draft ready for human review. A successful dry run reports a complete local package without implying that a GitHub draft exists. A failed run identifies the terminal safety gate and must not imply that a usable draft exists.

View File

@@ -0,0 +1,12 @@
# Release scenario router
Select exactly one workflow before running release-note scripts.
| Scenario | Required input | Content boundary | Human interaction |
| --- | --- | --- | --- |
| Stable release | Version or milestone | Existing stable release range | Existing stable workflow |
| Preview release | ADO build URL or build ID | Previous published release to the build's exact source commit | Final draft review only |
Use [stable-release.md](./stable-release.md) for the existing milestone workflow.
Use [preview-release.md](./preview-release.md) when the requested output is a GitHub draft prerelease. Preview mode derives the version, branch, previous tag, and target commit from immutable build and release metadata. Do not run milestone assignment or modify PR labels in preview mode.

View File

@@ -0,0 +1,136 @@
# Preview release scenario
Convert one successful PowerToys ADO release-candidate build into a complete GitHub draft prerelease. Run autonomously to either a verified draft or a terminal failure report. Never ask for a decision after starting and never publish a release.
## Inputs
- One ADO build URL or numeric build ID.
- Optional engineering flags: dry run and output directory.
Use:
```text
Generated Files/ReleaseNotes/preview-<buildId>/
```
for every local artifact.
## Candidate eligibility
During release preparation, preview builds may come from either `main` or `stable`; both are official supported patterns. Any successful build from trusted release definition `76541` on either branch is eligible regardless of its resolved intent, channel, or `shouldPublishPreview` value.
`get-release-build-metadata.ps1` first reads the pipeline-published `release-metadata.json` and falls back to immutable versioning logs for older builds. Preserve the original metadata as audit evidence, but do not use release intent or channel as eligibility gates. The requested workflow determines that GitHub receives a draft prerelease.
Reject builds from other branches, failed or incomplete builds, non-release definitions, unresolved versions or commits, and missing or invalid assets.
## Workflow
1. Resolve and validate build metadata:
```powershell
$context = .\.github\skills\release-note-generation\scripts\get-release-build-metadata.ps1 `
-Build '<ADO URL or ID>' `
-OutputPath '<run directory>\release-context.json'
```
2. Select the previous published release:
```powershell
$baseline = .\.github\skills\release-note-generation\scripts\get-previous-published-release.ps1 `
-TargetTag "v$($context.version)" `
-QueuedAt $context.queuedAt `
-OutputPath '<run directory>\previous-release.json'
```
3. Fetch the candidate source commit, baseline tag, and required history. Calculate the semantic delta:
```powershell
$delta = .\.github\skills\release-note-generation\scripts\get-preview-release-delta.ps1 `
-PreviousCommit $baseline.sourceCommit `
-TargetCommit $context.sourceCommit `
-OutputDirectory '<run directory>'
```
Follow [preview delta resolution](../preview-delta-resolution.md). A same-lineage rollback is fatal. Added, removed, and unattributed changes are all review evidence and must not be silently dropped.
4. Collect normalized metadata for added PRs:
First create `<run directory>\MemberList.md` from the **PowerToys core team** section in [`COMMUNITY.md`](../../../../../COMMUNITY.md), following the exact username format in [Step 1.0.1](../step1-collection.md#101-generate-memberlistmd-required). Then run:
```powershell
.\.github\skills\release-note-generation\scripts\collect-pr-metadata.ps1 `
-DeltaPath '<run directory>\delta-prs.json' `
-OutputDirectory '<run directory>' `
-MemberListPath '<run directory>\MemberList.md'
```
5. Generate summaries and compose `release-notes.md` using the existing label, contributor-attribution, grouping, and formatting conventions. Preview-specific rules:
- Do not assign milestones or change labels.
- Put unlabeled PRs under `General`.
- Use conservative title-based wording when summary confidence is low.
- Place `Installer Hashes` immediately after the title and short public introduction, before `Highlights` and all change sections.
- Include removed PRs under `Differences from the previous preview`.
- Include unattributed commits under `Changes needing final review`.
- Enclose the generated body in the managed markers documented in [preview draft safety](../preview-draft-safety.md).
6. Create `release-manifest.json` from the build, baseline, and delta outputs. Keep this manifest in the local audit package; never upload it as a GitHub release asset:
```powershell
.\.github\skills\release-note-generation\scripts\new-preview-release-manifest.ps1 `
-ContextPath '<run directory>\release-context.json' `
-PreviousReleasePath '<run directory>\previous-release.json' `
-DeltaDirectory '<run directory>' `
-OutputPath '<run directory>\release-manifest.json'
```
7. Download and validate all release assets:
```powershell
.\.github\skills\release-note-generation\scripts\prepare-release-assets.ps1 `
-BuildId $context.buildId `
-Version $context.version `
-DestinationFolder '<run directory>\assets'
```
Keep the generated `assets-manifest.json` in the local audit package. Do not upload it as a GitHub release asset.
8. In dry-run mode, write the complete local review report and stop without contacting GitHub:
```powershell
.\.github\skills\release-note-generation\scripts\verify-draft-preview-release.ps1 `
-Tag "v$($context.version)" `
-TargetCommit $context.sourceCommit `
-AssetsDirectory '<run directory>\assets' `
-BodyPath '<run directory>\release-notes.md' `
-ContextPath '<run directory>\release-context.json' `
-PreviousReleasePath '<run directory>\previous-release.json' `
-DeltaDirectory '<run directory>' `
-OutputPath '<run directory>\final-review.md' `
-DryRun
```
9. Otherwise, create or update the draft prerelease:
```powershell
.\.github\skills\release-note-generation\scripts\upsert-draft-preview-release.ps1 `
-Tag "v$($context.version)" `
-TargetCommit $context.sourceCommit `
-BodyPath '<run directory>\release-notes.md' `
-AssetsDirectory '<run directory>\assets'
```
10. Verify the resulting draft:
```powershell
.\.github\skills\release-note-generation\scripts\verify-draft-preview-release.ps1 `
-Tag "v$($context.version)" `
-TargetCommit $context.sourceCommit `
-AssetsDirectory '<run directory>\assets' `
-ContextPath '<run directory>\release-context.json' `
-PreviousReleasePath '<run directory>\previous-release.json' `
-DeltaDirectory '<run directory>' `
-OutputPath '<run directory>\final-review.md'
```
Return the draft URL and the final review package described in [preview reporting](../preview-reporting.md).

View File

@@ -0,0 +1,12 @@
# Stable release scenario
Use the existing release-note workflow for a milestone or stable release version:
1. Confirm the release version.
2. Follow [Step 1](../step1-collection.md) to collect PRs and manage milestones.
3. Follow [Step 2](../step2-labeling.md) to classify PRs.
4. Follow [Step 3](../step3-review-grouping.md) to produce PR summaries and grouped data.
5. Follow [Step 4](../step4-summarization.md) to compose final release notes.
6. Run [prepare-release-assets.ps1](../../scripts/prepare-release-assets.ps1) when release assets are required.
Do not apply the preview-specific baseline, branch-transition, draft-upsert, or managed-body rules to the stable workflow.

View File

@@ -0,0 +1,123 @@
<#
.SYNOPSIS
Collects normalized PowerToys release-note metadata for explicit PRs.
.DESCRIPTION
Preview releases already have a semantic PR set, so this script skips
milestone and label mutation. Existing labels are filtered using the
release-note conventions and unlabeled PRs are assigned to General.
.EXAMPLE
.\collect-pr-metadata.ps1 -DeltaPath .\delta-prs.json -OutputDirectory .\preview-154000000
#>
[CmdletBinding()]
param(
[int[]]$PrNumbers,
[string]$DeltaPath,
[Parameter(Mandatory)][string]$OutputDirectory,
[string]$Repo = "microsoft/PowerToys",
[string]$MemberListPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
if ($DeltaPath) {
$delta = @(Get-Content -LiteralPath $DeltaPath -Raw | ConvertFrom-Json)
$PrNumbers = @($delta | ForEach-Object { [int]$_.number })
}
$PrNumbers = @($PrNumbers | Where-Object { $_ -gt 0 } | Sort-Object -Unique)
if (-not $PSBoundParameters.ContainsKey("PrNumbers") -and -not $DeltaPath) {
throw "Provide either -PrNumbers or -DeltaPath."
}
if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
throw "GitHub CLI ('gh') is required. Install it and run 'gh auth login'."
}
if (-not $MemberListPath) {
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..\..")).Path
$MemberListPath = Join-Path $repoRoot "Generated Files\ReleaseNotes\MemberList.md"
}
if (-not (Test-Path -LiteralPath $MemberListPath -PathType Leaf)) {
throw "Required PowerToys member list not found: $MemberListPath"
}
$members = @(
Get-Content -LiteralPath $MemberListPath |
Where-Object { $_ -notmatch '^\s*```' -and -not [string]::IsNullOrWhiteSpace($_) } |
ForEach-Object { $_.Trim() }
)
if ($members.Count -eq 0) {
throw "Required PowerToys member list is empty: $MemberListPath"
}
$memberSet = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($member in $members) {
[void]$memberSet.Add($member)
}
$rows = @()
foreach ($number in $PrNumbers) {
Write-Host "Fetching PR #$number..." -ForegroundColor Cyan
$json = gh pr view $number `
--repo $Repo `
--json number,title,labels,author,url,body,mergedAt
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($json)) {
throw "Failed to fetch required PR #$number from $Repo."
}
$pr = $json | ConvertFrom-Json
$labels = @($pr.labels | ForEach-Object { $_.name } | Where-Object {
$_ -like "Product-*" -or
$_ -like "Area-*" -or
$_ -like "GitHub*" -or
$_ -like "*Plugin" -or
$_ -like "Issue-*"
})
if ($labels.Count -eq 0) {
$labels = @("General")
}
$author = [string]$pr.author.login
$needThanks = if ($author -and -not $memberSet.Contains($author)) { $author } else { "" }
$body = if ($pr.body) {
(([string]$pr.body -replace "`r", "") -replace "`n", " ") -replace "\s+", " "
}
else {
""
}
$rows += [pscustomobject]@{
Id = [int]$pr.number
Title = [string]$pr.title
Labels = ($labels -join ", ")
Author = $author
Url = [string]$pr.url
Body = $body
CopilotSummary = ""
NeedThanks = $needThanks
MergedAt = [string]$pr.mergedAt
}
}
$sorted = @($rows | Sort-Object @{ Expression = { ($_.Labels -split ",")[0] } }, Id)
New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
$jsonPath = Join-Path $OutputDirectory "milestone_prs.json"
$csvPath = Join-Path $OutputDirectory "sorted_prs.csv"
ConvertTo-Json -InputObject $sorted -Depth 6 | Set-Content -LiteralPath $jsonPath -Encoding utf8
if ($sorted.Count -gt 0) {
$sorted | Export-Csv -LiteralPath $csvPath -NoTypeInformation -Encoding utf8
}
else {
'"Id","Title","Labels","Author","Url","Body","CopilotSummary","NeedThanks","MergedAt"' |
Set-Content -LiteralPath $csvPath -Encoding utf8
}
[pscustomobject]@{
count = $sorted.Count
jsonPath = (Resolve-Path -LiteralPath $jsonPath).Path
csvPath = (Resolve-Path -LiteralPath $csvPath).Path
}

View File

@@ -0,0 +1,329 @@
<#
.SYNOPSIS
Calculates semantic PR changes between two exact PowerToys release commits.
.DESCRIPTION
Uses a direct range for same-lineage releases and a symmetric comparison
from the merge base for branch transitions. PR numbers, cherry-pick source
annotations, and stable patch IDs prevent equivalent changes from being
reported as new solely because their commit SHAs differ.
.EXAMPLE
.\get-preview-release-delta.ps1 -PreviousCommit abc123 -TargetCommit def456 -OutputDirectory .\preview-154000000
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$PreviousCommit,
[Parameter(Mandatory)][string]$TargetCommit,
[string]$Repo = "microsoft/PowerToys",
[string]$RepoPath = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..\..")).Path,
[Parameter(Mandatory)][string]$OutputDirectory,
[switch]$Fetch,
[switch]$NoGitHubLookup
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Invoke-Git {
param([Parameter(ValueFromRemainingArguments)][string[]]$Arguments)
$output = & git -C $RepoPath @Arguments 2>&1
if ($LASTEXITCODE -ne 0) {
throw "git $($Arguments -join ' ') failed: $($output -join "`n")"
}
return $output
}
function Resolve-Commit {
param([Parameter(Mandatory)][string]$Commit)
$resolved = Invoke-Git rev-parse --verify "$Commit^{commit}"
$sha = ([string]$resolved).Trim()
if ($sha -notmatch "^[0-9a-fA-F]{40}$") {
throw "Commit '$Commit' did not resolve to a full SHA."
}
return $sha.ToLowerInvariant()
}
function Test-Ancestor {
param(
[Parameter(Mandatory)][string]$Ancestor,
[Parameter(Mandatory)][string]$Descendant
)
& git -C $RepoPath merge-base --is-ancestor $Ancestor $Descendant 2>$null
if ($LASTEXITCODE -eq 0) {
return $true
}
if ($LASTEXITCODE -eq 1) {
return $false
}
throw "git merge-base --is-ancestor failed for $Ancestor and $Descendant."
}
function Get-PatchId {
param([Parameter(Mandatory)][string]$Sha)
$patchOutput = & git -C $RepoPath show --pretty=format: --no-ext-diff --binary $Sha |
& git -C $RepoPath patch-id --stable
if ($LASTEXITCODE -ne 0 -or -not $patchOutput) {
return $null
}
$first = @($patchOutput)[0]
if ([string]$first -match "^([0-9a-fA-F]{40})\s") {
return $matches[1].ToLowerInvariant()
}
return $null
}
function Get-SubjectPrNumber {
param([Parameter(Mandatory)][string]$Subject)
if ($Subject -match "\(#(\d+)\)\s*$") {
return [int]$matches[1]
}
if ($Subject -match "^Merge pull request #(\d+)\b") {
return [int]$matches[1]
}
return $null
}
function Get-AssociatedPrNumber {
param([Parameter(Mandatory)][string]$Sha)
if ($NoGitHubLookup) {
return $null
}
if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
throw "GitHub CLI ('gh') is required for commit-to-PR fallback resolution."
}
$json = gh api `
-H "Accept: application/vnd.github+json" `
"repos/$Repo/commits/$Sha/pulls" 2>$null
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($json)) {
return $null
}
$pulls = @($json | ConvertFrom-Json)
$selected = @($pulls | Where-Object { $_.merged_at } | Sort-Object merged_at -Descending | Select-Object -First 1)
if ($selected.Count -eq 0) {
$selected = @($pulls | Select-Object -First 1)
}
if ($selected.Count -gt 0) {
return [int]$selected[0].number
}
return $null
}
function Get-CommitRecord {
param([Parameter(Mandatory)][string]$Sha)
$subject = ([string](Invoke-Git show -s --format=%s $Sha)).Trim()
$body = ([string](Invoke-Git show -s --format=%B $Sha)).Trim()
$prNumber = Get-SubjectPrNumber -Subject $subject
$identitySource = if ($prNumber) { "subject" } else { $null }
$cherryPickedFrom = $null
if (-not $prNumber) {
$prNumber = Get-AssociatedPrNumber -Sha $Sha
if ($prNumber) {
$identitySource = "github-associated-pr"
}
}
if (-not $prNumber -and $body -match "\(cherry picked from commit ([0-9a-fA-F]{7,40})\)") {
$cherryPickedFrom = $matches[1].ToLowerInvariant()
$sourceSha = Invoke-Git rev-parse --verify "$cherryPickedFrom^{commit}"
if ($sourceSha) {
$sourceSubject = ([string](Invoke-Git show -s --format=%s ([string]$sourceSha).Trim())).Trim()
$prNumber = Get-SubjectPrNumber -Subject $sourceSubject
if (-not $prNumber) {
$prNumber = Get-AssociatedPrNumber -Sha ([string]$sourceSha).Trim()
}
if ($prNumber) {
$identitySource = "cherry-pick-source"
}
}
}
$patchId = Get-PatchId -Sha $Sha
$identity = if ($prNumber) {
"pr:$prNumber"
}
elseif ($patchId) {
"patch:$patchId"
}
else {
"commit:$Sha"
}
[pscustomobject]@{
sha = $Sha
subject = $subject
prNumber = $prNumber
identity = $identity
identitySource = if ($identitySource) { $identitySource } elseif ($patchId) { "patch-id" } else { "unattributed" }
patchId = $patchId
cherryPickedFrom = $cherryPickedFrom
}
}
function Get-RangeRecords {
param(
[Parameter(Mandatory)][string]$Start,
[Parameter(Mandatory)][string]$End
)
$commits = @(Invoke-Git rev-list --reverse "$Start..$End" | Where-Object { $_ })
$records = @()
foreach ($commit in $commits) {
$records += Get-CommitRecord -Sha ([string]$commit).Trim()
}
return $records
}
function New-IdentitySet {
param([Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Records)
$set = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($record in $Records) {
[void]$set.Add([string]$record.identity)
}
return ,$set
}
function Resolve-CrossSidePatchIdentities {
param(
[Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Left,
[Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Right
)
foreach ($record in @($Left | Where-Object { -not $_.prNumber -and $_.patchId })) {
$candidateNumbers = @(
$Right |
Where-Object { $_.prNumber -and $_.patchId -eq $record.patchId } |
ForEach-Object { [int]$_.prNumber } |
Sort-Object -Unique
)
if ($candidateNumbers.Count -gt 1) {
throw "Patch ID '$($record.patchId)' maps to multiple PR numbers: $($candidateNumbers -join ', ')."
}
if ($candidateNumbers.Count -eq 1) {
$record.prNumber = $candidateNumbers[0]
$record.identity = "pr:$($candidateNumbers[0])"
$record.identitySource = "patch-id-equivalent-pr"
}
}
}
function Get-PrOutput {
param(
[Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Records,
[Parameter(Mandatory)][AllowEmptyCollection()][System.Collections.Generic.HashSet[string]]$OtherSide,
[switch]$IncludeExisting
)
$groups = @($Records | Where-Object {
$_.prNumber -and ($IncludeExisting -or -not $OtherSide.Contains([string]$_.identity))
} | Group-Object prNumber)
return @($groups | ForEach-Object {
$groupRecords = @($_.Group)
[pscustomobject]@{
number = [int]$_.Name
commits = @($groupRecords | ForEach-Object { $_.sha })
subjects = @($groupRecords | ForEach-Object { $_.subject } | Select-Object -Unique)
identitySources = @($groupRecords | ForEach-Object { $_.identitySource } | Select-Object -Unique)
}
} | Sort-Object number)
}
if ($Fetch) {
Invoke-Git fetch origin --tags --prune | Out-Null
}
$previousSha = Resolve-Commit -Commit $PreviousCommit
$targetSha = Resolve-Commit -Commit $TargetCommit
$previousIsAncestor = Test-Ancestor -Ancestor $previousSha -Descendant $targetSha
$targetIsAncestor = Test-Ancestor -Ancestor $targetSha -Descendant $previousSha
if ($targetIsAncestor -and -not $previousIsAncestor) {
throw "Target commit $targetSha predates published baseline $previousSha on the same lineage."
}
$mergeBase = $null
$previousRecords = @()
$targetRecords = @()
if ($previousIsAncestor) {
$deltaMode = "same-lineage"
$targetRecords = @(Get-RangeRecords -Start $previousSha -End $targetSha)
}
else {
$deltaMode = "branch-transition"
$mergeBase = ([string](Invoke-Git merge-base $previousSha $targetSha)).Trim().ToLowerInvariant()
$previousRecords = @(Get-RangeRecords -Start $mergeBase -End $previousSha)
$targetRecords = @(Get-RangeRecords -Start $mergeBase -End $targetSha)
}
Resolve-CrossSidePatchIdentities -Left $previousRecords -Right $targetRecords
Resolve-CrossSidePatchIdentities -Left $targetRecords -Right $previousRecords
$previousIdentities = New-IdentitySet -Records $previousRecords
$targetIdentities = New-IdentitySet -Records $targetRecords
$addedPrs = Get-PrOutput -Records $targetRecords -OtherSide $previousIdentities
$removedPrs = if ($deltaMode -eq "branch-transition") {
Get-PrOutput -Records $previousRecords -OtherSide $targetIdentities
}
else {
@()
}
$unattributed = @($targetRecords | Where-Object {
-not $_.prNumber -and -not $previousIdentities.Contains([string]$_.identity)
})
$removedUnattributed = @($previousRecords | Where-Object {
-not $_.prNumber -and -not $targetIdentities.Contains([string]$_.identity)
})
$commonIdentities = @($targetRecords | Where-Object {
$previousIdentities.Contains([string]$_.identity)
} | ForEach-Object { $_.identity } | Select-Object -Unique)
New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
$commitOutput = [ordered]@{
schemaVersion = 1
previousCommit = $previousSha
targetCommit = $targetSha
deltaMode = $deltaMode
mergeBase = $mergeBase
previousSide = $previousRecords
targetSide = $targetRecords
commonIdentities = $commonIdentities
removedUnattributedCommits = $removedUnattributed
}
$commitPath = Join-Path $OutputDirectory "delta-commits.json"
$addedPath = Join-Path $OutputDirectory "delta-prs.json"
$removedPath = Join-Path $OutputDirectory "removed-prs.json"
$unattributedPath = Join-Path $OutputDirectory "unattributed-commits.json"
$commitOutput | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $commitPath -Encoding utf8
ConvertTo-Json -InputObject @($addedPrs) -Depth 6 | Set-Content -LiteralPath $addedPath -Encoding utf8
ConvertTo-Json -InputObject @($removedPrs) -Depth 6 | Set-Content -LiteralPath $removedPath -Encoding utf8
ConvertTo-Json -InputObject @($unattributed) -Depth 6 | Set-Content -LiteralPath $unattributedPath -Encoding utf8
[pscustomobject]@{
previousCommit = $previousSha
targetCommit = $targetSha
deltaMode = $deltaMode
mergeBase = $mergeBase
addedPrNumbers = @($addedPrs | ForEach-Object { $_.number })
removedPrNumbers = @($removedPrs | ForEach-Object { $_.number })
unattributedCommitCount = $unattributed.Count
outputDirectory = (Resolve-Path -LiteralPath $OutputDirectory).Path
}

View File

@@ -0,0 +1,165 @@
<#
.SYNOPSIS
Selects the published PowerToys release immediately preceding a candidate.
.DESCRIPTION
Includes stable releases and prereleases, excludes drafts and the target
tag, and selects the latest release published before the candidate build
entered the queue.
.EXAMPLE
.\get-previous-published-release.ps1 -TargetTag v0.101.2181.0 -QueuedAt 2026-08-06T06:00:00Z
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$TargetTag,
[Parameter(Mandatory)][datetime]$QueuedAt,
[string]$Repo = "microsoft/PowerToys",
[string]$RepoPath = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..\..")).Path,
[string]$OutputPath,
[string]$ReleasesJsonPath,
[switch]$SkipSourceCommitResolution
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Get-Releases {
if ($ReleasesJsonPath) {
return @(Get-Content -LiteralPath $ReleasesJsonPath -Raw | ConvertFrom-Json)
}
if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
throw "GitHub CLI ('gh') is required. Install it and run 'gh auth login'."
}
$json = gh api --paginate --slurp "repos/$Repo/releases?per_page=100"
if ($LASTEXITCODE -ne 0) {
throw "Failed to list releases for $Repo."
}
$pages = $json | ConvertFrom-Json
$all = @()
foreach ($page in @($pages)) {
$all += @($page)
}
return $all
}
function Get-ReleaseManifest {
param([Parameter(Mandatory)]$Release)
if ($ReleasesJsonPath -or -not $Release.assets) {
return $null
}
$manifestAsset = @($Release.assets | Where-Object { $_.name -eq "release-manifest.json" }) | Select-Object -First 1
if (-not $manifestAsset) {
return $null
}
$temporaryDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "pt-release-manifest-$([Guid]::NewGuid().ToString('N'))"
try {
New-Item -ItemType Directory -Path $temporaryDirectory -Force | Out-Null
gh release download $Release.tag_name `
--repo $Repo `
--pattern "release-manifest.json" `
--dir $temporaryDirectory `
--clobber | Out-Null
if ($LASTEXITCODE -ne 0) {
return $null
}
$path = Join-Path $temporaryDirectory "release-manifest.json"
if (Test-Path -LiteralPath $path) {
return Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
}
return $null
}
finally {
Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force -ErrorAction SilentlyContinue
}
}
function Resolve-ReleaseCommit {
param(
[Parameter(Mandatory)]$Release,
$Manifest
)
if ($Manifest -and [string]$Manifest.sourceCommit -match "^[0-9a-fA-F]{40}$") {
return ([string]$Manifest.sourceCommit).ToLowerInvariant()
}
if ([string]$Release.target_commitish -match "^[0-9a-fA-F]{40}$") {
return ([string]$Release.target_commitish).ToLowerInvariant()
}
if ($RepoPath -and (Test-Path -LiteralPath $RepoPath)) {
$tagCommit = git -C $RepoPath rev-parse --verify "$($Release.tag_name)^{commit}" 2>$null
if ($LASTEXITCODE -eq 0 -and [string]$tagCommit -match "^[0-9a-fA-F]{40}$") {
return ([string]$tagCommit).Trim().ToLowerInvariant()
}
}
if (-not $ReleasesJsonPath) {
$commit = gh api "repos/$Repo/commits/$($Release.tag_name)" --jq ".sha" 2>$null
if ($LASTEXITCODE -eq 0 -and [string]$commit -match "^[0-9a-fA-F]{40}$") {
return ([string]$commit).Trim().ToLowerInvariant()
}
}
throw "Could not resolve an immutable source commit for release '$($Release.tag_name)'."
}
$candidateQueueTime = $QueuedAt.ToUniversalTime()
$eligible = @(
Get-Releases |
Where-Object {
-not [bool]$_.draft -and
[string]$_.tag_name -ne $TargetTag -and
$_.published_at -and
([datetime]$_.published_at).ToUniversalTime() -lt $candidateQueueTime
} |
Sort-Object { ([datetime]$_.published_at).ToUniversalTime() } -Descending
)
if ($eligible.Count -eq 0) {
throw "No published PowerToys release predates candidate queue time $($candidateQueueTime.ToString('o'))."
}
$release = $eligible[0]
$manifest = Get-ReleaseManifest -Release $release
$sourceCommit = if ($SkipSourceCommitResolution) {
$null
}
else {
Resolve-ReleaseCommit -Release $release -Manifest $manifest
}
$sourceBranch = $null
if ($manifest -and $manifest.sourceBranch) {
$sourceBranch = [string]$manifest.sourceBranch
}
$result = [ordered]@{
schemaVersion = 1
tag = [string]$release.tag_name
name = [string]$release.name
publishedAt = ([datetime]$release.published_at).ToUniversalTime().ToString("o")
prerelease = [bool]$release.prerelease
url = if ($release.html_url) { [string]$release.html_url } else { $null }
sourceBranch = $sourceBranch
sourceCommit = $sourceCommit
source = if ($manifest) { "release-manifest" } else { "release-tag" }
}
if ($OutputPath) {
$parent = Split-Path -Parent $OutputPath
if ($parent) {
New-Item -ItemType Directory -Path $parent -Force | Out-Null
}
$result | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $OutputPath -Encoding utf8
}
[pscustomobject]$result

View File

@@ -0,0 +1,433 @@
<#
.SYNOPSIS
Resolves and validates PowerToys preview-release metadata from an ADO build.
.DESCRIPTION
Accepts a microsoft/Dart build URL or numeric build ID. The script prefers
pipeline-published release-metadata.json, overlays immutable ADO build
identity, and falls back to release-pipeline logs for older builds.
.EXAMPLE
.\get-release-build-metadata.ps1 -Build 154000000 -OutputPath .\release-context.json
.EXAMPLE
.\get-release-build-metadata.ps1 -Build 'https://microsoft.visualstudio.com/Dart/_build/results?buildId=154000000'
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, Position = 0)]
[string]$Build,
[string]$Organization = "https://dev.azure.com/microsoft",
[string]$Project = "Dart",
[ValidateRange(1, [int]::MaxValue)]
[int]$ExpectedDefinitionId = 76541,
[string]$OutputPath,
[string]$BuildJsonPath,
[string]$ArtifactsJsonPath,
[string]$MetadataJsonPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$env:AZURE_CORE_NO_PROMPT = "true"
. (Join-Path $PSScriptRoot "web-response-content.ps1")
$defaultExtensionDirectory = Join-Path $env:USERPROFILE ".azure\cliextensions"
if (-not $env:AZURE_EXTENSION_DIR -and (Test-Path -LiteralPath $defaultExtensionDirectory)) {
$inaccessibleExtension = Get-ChildItem "$defaultExtensionDirectory\*\*.dist-info" -Directory -ErrorAction SilentlyContinue |
Where-Object {
try {
[System.IO.Directory]::GetFiles($_.FullName) | Out-Null
$false
}
catch {
$true
}
} |
Select-Object -First 1
if ($inaccessibleExtension) {
$cleanExtensionDirectory = Join-Path $env:USERPROFILE ".azure\cliextensions_clean"
New-Item -ItemType Directory -Path $cleanExtensionDirectory -Force | Out-Null
$env:AZURE_EXTENSION_DIR = $cleanExtensionDirectory
}
}
function Resolve-BuildId {
param([Parameter(Mandatory)][string]$Value)
$trimmed = $Value.Trim()
if ($trimmed -match "^\d+$") {
return [int]::Parse($trimmed)
}
try {
$uri = [Uri]$trimmed
}
catch {
throw "Build must be a numeric build ID or a valid Azure DevOps build URL."
}
$match = [regex]::Match($uri.Query, "(?:^\?|&)buildId=(\d+)(?:&|$)", [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
if (-not $match.Success) {
throw "Azure DevOps URL does not contain a numeric buildId query parameter."
}
return [int]::Parse($match.Groups[1].Value)
}
function Invoke-Az {
param([Parameter(ValueFromRemainingArguments)][string[]]$Arguments)
if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
throw "Azure CLI ('az') is required. Install it and run 'az login'."
}
$stderrPath = [System.IO.Path]::GetTempFileName()
try {
$output = & az @Arguments 2>$stderrPath
$stderr = Get-Content -LiteralPath $stderrPath -Raw -ErrorAction SilentlyContinue
if ($LASTEXITCODE -ne 0) {
throw "az $($Arguments -join ' ') failed: $stderr"
}
return $output
}
finally {
Remove-Item -LiteralPath $stderrPath -Force -ErrorAction SilentlyContinue
}
}
function Get-AdoToken {
$token = Invoke-Az account get-access-token `
--resource "499b84ac-1321-427f-aa17-267ca6975798" `
--query accessToken `
--output tsv
if ([string]::IsNullOrWhiteSpace($token)) {
throw "Failed to acquire an Azure DevOps access token. Run 'az login'."
}
return [string]$token
}
function Get-ArtifactFileUrl {
param(
[Parameter(Mandatory)][string]$BaseUrl,
[Parameter(Mandatory)][string]$SubPath
)
$encodedSubPath = [Uri]::EscapeDataString($SubPath)
$question = $BaseUrl.IndexOf("?")
if ($question -lt 0) {
return "${BaseUrl}?format=file&subPath=$encodedSubPath"
}
$base = $BaseUrl.Substring(0, $question)
$parameters = $BaseUrl.Substring($question + 1) -split "&" | Where-Object {
$_ -and $_ -notmatch "^(format|subPath)="
}
$parameters = @($parameters) + @("format=file", "subPath=$encodedSubPath")
return "${base}?$($parameters -join '&')"
}
function Get-ArtifactMetadata {
param(
[Parameter(Mandatory)]$Artifacts,
[Parameter(Mandatory)][string]$Token
)
$orderedArtifacts = @(
$Artifacts |
Where-Object { $_.name -in @("release-metadata", "build-x64-Release", "build-arm64-Release") } |
Sort-Object @{
Expression = {
switch ($_.name) {
"release-metadata" { 0 }
"build-x64-Release" { 1 }
default { 2 }
}
}
}
)
foreach ($artifact in $orderedArtifacts) {
if (-not $artifact.resource -or -not $artifact.resource.downloadUrl) {
continue
}
foreach ($subPath in @("/release-metadata.json", "release-metadata.json")) {
$url = Get-ArtifactFileUrl -BaseUrl $artifact.resource.downloadUrl -SubPath $subPath
try {
$response = Invoke-WebRequest `
-Uri $url `
-Headers @{ Authorization = "Bearer $Token" } `
-TimeoutSec 15
$text = ConvertFrom-WebResponseContent -Content $response.Content
if (-not [string]::IsNullOrWhiteSpace($text)) {
return $text | ConvertFrom-Json
}
}
catch {
# Most artifacts do not contain this file. Continue to the next candidate.
}
}
}
return $null
}
function Get-LogMetadata {
param(
[Parameter(Mandatory)][int]$BuildId,
[Parameter(Mandatory)][string]$Token,
[Parameter(Mandatory)][string]$Organization,
[Parameter(Mandatory)][string]$Project
)
$headers = @{ Authorization = "Bearer $Token" }
$logUrls = @()
$timelineUri = "$Organization/$Project/_apis/build/builds/$BuildId/timeline?api-version=7.1"
try {
$timeline = Invoke-RestMethod -Uri $timelineUri -Headers $headers -TimeoutSec 30
$logUrls = @(
$timeline.records |
Where-Object {
$_.log -and $_.log.url -and
$_.name -in @("Prepare versioning", "Resolve symbol version")
} |
ForEach-Object { [string]$_.log.url } |
Select-Object -Unique
)
}
catch {
$logUrls = @()
}
if ($logUrls.Count -eq 0) {
$logsUri = "$Organization/$Project/_apis/build/builds/$BuildId/logs?api-version=7.1"
$logs = Invoke-RestMethod -Uri $logsUri -Headers $headers -TimeoutSec 30
$logUrls = @($logs.value | Sort-Object id -Descending | ForEach-Object { [string]$_.url })
}
$version = $null
$channel = $null
$intent = $null
foreach ($logUrl in $logUrls) {
if (-not $logUrl) {
continue
}
try {
$text = Invoke-RestMethod -Uri $logUrl -Headers $headers -TimeoutSec 30
$joined = if ($text -is [array]) { $text -join "`n" } else { [string]$text }
if (-not $version -and $joined -match "Resolved PowerToys version:\s*(\d+\.\d+\.\d+\.\d+)") {
$version = $matches[1]
}
if (-not $channel -and $joined -match "Resolved release channel:\s*([a-z-]+)") {
$channel = $matches[1]
}
if (-not $intent -and $joined -match "Resolved build intent:\s*([a-z-]+)") {
$intent = $matches[1]
}
if ($version -and $channel -and $intent) {
break
}
}
catch {
# A single inaccessible log is not fatal if another log has the metadata.
}
}
if (-not $version -and -not $channel -and -not $intent) {
return $null
}
return [pscustomobject]@{
version = $version
channel = $channel
intent = $intent
shouldPublishPreview = ($intent -eq "preview-release")
}
}
function Get-PropertyValue {
param(
$Object,
[Parameter(Mandatory)][string]$Name
)
if ($null -eq $Object) {
return $null
}
$property = $Object.PSObject.Properties[$Name]
if ($null -eq $property) {
return $null
}
return $property.Value
}
function ConvertTo-Boolean {
param($Value)
if ($Value -is [bool]) {
return $Value
}
if ($null -eq $Value) {
return $false
}
return [string]$Value -eq "true"
}
$buildId = Resolve-BuildId -Value $Build
if ($BuildJsonPath) {
$buildObject = Get-Content -LiteralPath $BuildJsonPath -Raw | ConvertFrom-Json
}
else {
$extension = Invoke-Az extension list --query "[?name=='azure-devops'].name" --output tsv
if ([string]::IsNullOrWhiteSpace($extension)) {
Invoke-Az extension add --name azure-devops --yes --only-show-errors | Out-Null
}
Invoke-Az devops configure --defaults "organization=$Organization" "project=$Project" | Out-Null
$buildObject = (Invoke-Az pipelines build show --id $buildId --output json) | ConvertFrom-Json
}
if ([int]$buildObject.id -ne $buildId) {
throw "ADO returned build '$($buildObject.id)' while build '$buildId' was requested."
}
$artifacts = @()
if ($ArtifactsJsonPath) {
$artifacts = @(Get-Content -LiteralPath $ArtifactsJsonPath -Raw | ConvertFrom-Json)
}
elseif (-not $MetadataJsonPath) {
$artifacts = @((Invoke-Az pipelines runs artifact list --run-id $buildId --output json) | ConvertFrom-Json)
}
$pipelineMetadata = $null
$metadataSource = $null
$token = $null
if ($MetadataJsonPath) {
$pipelineMetadata = Get-Content -LiteralPath $MetadataJsonPath -Raw | ConvertFrom-Json
$metadataSource = "file"
}
elseif ($artifacts.Count -gt 0) {
$token = Get-AdoToken
$pipelineMetadata = Get-ArtifactMetadata -Artifacts $artifacts -Token $token
if ($pipelineMetadata) {
$metadataSource = "artifact"
}
}
if (-not $pipelineMetadata) {
if (-not $token) {
$token = Get-AdoToken
}
$pipelineMetadata = Get-LogMetadata `
-BuildId $buildId `
-Token $token `
-Organization $Organization `
-Project $Project
if ($pipelineMetadata) {
$metadataSource = "pipeline-log"
}
}
$templateParameters = Get-PropertyValue -Object $buildObject -Name "templateParameters"
$templateVersion = Get-PropertyValue -Object $templateParameters -Name "VersionNumber"
$version = Get-PropertyValue -Object $pipelineMetadata -Name "version"
if ([string]::IsNullOrWhiteSpace([string]$version)) {
$version = $templateVersion
}
$definitionId = [int]$buildObject.definition.id
$sourceBranch = [string]$buildObject.sourceBranch
$sourceCommit = [string]$buildObject.sourceVersion
$result = [string]$buildObject.result
$channel = [string](Get-PropertyValue -Object $pipelineMetadata -Name "channel")
$intent = [string](Get-PropertyValue -Object $pipelineMetadata -Name "intent")
$shouldPublishPreview = ConvertTo-Boolean (Get-PropertyValue -Object $pipelineMetadata -Name "shouldPublishPreview")
$queuedAt = [string]$buildObject.queueTime
foreach ($field in @(
@{ Name = "definitionId"; Build = $definitionId },
@{ Name = "buildId"; Build = $buildId },
@{ Name = "sourceBranch"; Build = $sourceBranch },
@{ Name = "sourceCommit"; Build = $sourceCommit }
)) {
$metadataValue = Get-PropertyValue -Object $pipelineMetadata -Name $field.Name
if ($null -ne $metadataValue -and [string]$metadataValue -ne [string]$field.Build) {
throw "Pipeline metadata $($field.Name) '$metadataValue' conflicts with ADO build value '$($field.Build)'."
}
}
if ($definitionId -ne $ExpectedDefinitionId) {
throw "Build $buildId uses definition $definitionId; expected trusted release definition $ExpectedDefinitionId."
}
if ($result -ne "succeeded") {
throw "Build $buildId result is '$result'; only succeeded candidates are supported."
}
if ($sourceBranch -notin @("refs/heads/main", "refs/heads/stable")) {
throw "Build $buildId source branch '$sourceBranch' is not main or stable."
}
if ($sourceCommit -notmatch "^[0-9a-fA-F]{40}$") {
throw "Build $buildId does not identify a full immutable source commit."
}
if ([string]::IsNullOrWhiteSpace([string]$version) -or $version -notmatch "^\d+\.\d+\.\d+\.0$") {
throw "Build $buildId release version could not be resolved uniquely."
}
if ([string]::IsNullOrWhiteSpace($queuedAt)) {
throw "Build $buildId does not contain a queue timestamp."
}
try {
[void]([datetime]::Parse($queuedAt, [Globalization.CultureInfo]::InvariantCulture))
}
catch {
throw "Build $buildId queue timestamp '$queuedAt' is invalid."
}
$artifactNames = @($artifacts | ForEach-Object { [string]$_.name })
if ($artifactNames.Count -gt 0) {
foreach ($requiredArtifact in @("build-x64-Release", "build-arm64-Release")) {
if ($artifactNames -notcontains $requiredArtifact) {
throw "Build $buildId is missing required artifact '$requiredArtifact'."
}
}
}
$context = [ordered]@{
schemaVersion = 1
metadataSource = $metadataSource
organization = $Organization
project = $Project
definitionId = $definitionId
buildId = $buildId
buildUrl = "https://microsoft.visualstudio.com/$Project/_build/results?buildId=$buildId"
buildNumber = [string]$buildObject.buildNumber
result = $result
version = [string]$version
tag = "v$version"
channel = $channel
intent = $intent
sourceBranch = $sourceBranch
sourceCommit = $sourceCommit.ToLowerInvariant()
buildReason = [string]$buildObject.reason
queuedAt = $queuedAt
startedAt = [string]$buildObject.startTime
finishedAt = [string]$buildObject.finishTime
shouldPublishPreview = $shouldPublishPreview
artifactNames = $artifactNames
}
if ($OutputPath) {
$parent = Split-Path -Parent $OutputPath
if ($parent) {
New-Item -ItemType Directory -Path $parent -Force | Out-Null
}
$context | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutputPath -Encoding utf8
}
[pscustomobject]$context

View File

@@ -0,0 +1,47 @@
function Get-GitHubTagCommit {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Repo,
[Parameter(Mandatory)][string]$Tag
)
$stderrPath = [System.IO.Path]::GetTempFileName()
try {
$json = & gh api "repos/$Repo/commits/$Tag" 2>$stderrPath
$exitCode = $LASTEXITCODE
$stderr = Get-Content -LiteralPath $stderrPath -Raw -ErrorAction SilentlyContinue
if ($exitCode -eq 0) {
$sha = [string]($json | ConvertFrom-Json).sha
if ($sha -notmatch "^[0-9a-fA-F]{40}$") {
throw "GitHub returned an invalid commit for tag '$Tag'."
}
return $sha.ToLowerInvariant()
}
if ($stderr -match "(?s)(HTTP 404|Not Found|No commit found for SHA:.*HTTP 422)") {
return $null
}
throw "Failed to resolve Git tag '$Tag' in '$Repo'. $stderr"
}
finally {
Remove-Item -LiteralPath $stderrPath -Force -ErrorAction SilentlyContinue
}
}
function Assert-GitHubTagTarget {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Tag,
[AllowNull()][AllowEmptyString()][string]$ResolvedCommit,
[Parameter(Mandatory)][string]$TargetCommit
)
if ([string]::IsNullOrWhiteSpace($ResolvedCommit)) {
return
}
if ($ResolvedCommit -notmatch "^[0-9a-fA-F]{40}$") {
throw "Resolved commit for Git tag '$Tag' is invalid."
}
if ($ResolvedCommit -ne $TargetCommit) {
throw "Git tag '$Tag' resolves to '$ResolvedCommit', not target '$TargetCommit'."
}
}

View File

@@ -0,0 +1,60 @@
<#
.SYNOPSIS
Creates the auditable manifest for a generated PowerToys preview release.
.EXAMPLE
.\new-preview-release-manifest.ps1 -ContextPath .\release-context.json -PreviousReleasePath .\previous-release.json -DeltaDirectory . -OutputPath .\release-manifest.json
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$ContextPath,
[Parameter(Mandatory)][string]$PreviousReleasePath,
[Parameter(Mandatory)][string]$DeltaDirectory,
[Parameter(Mandatory)][string]$OutputPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$context = Get-Content -LiteralPath $ContextPath -Raw | ConvertFrom-Json
$baseline = Get-Content -LiteralPath $PreviousReleasePath -Raw | ConvertFrom-Json
$commitDelta = Get-Content -LiteralPath (Join-Path $DeltaDirectory "delta-commits.json") -Raw | ConvertFrom-Json
$added = @(Get-Content -LiteralPath (Join-Path $DeltaDirectory "delta-prs.json") -Raw | ConvertFrom-Json)
$removed = @(Get-Content -LiteralPath (Join-Path $DeltaDirectory "removed-prs.json") -Raw | ConvertFrom-Json)
$unattributed = @(Get-Content -LiteralPath (Join-Path $DeltaDirectory "unattributed-commits.json") -Raw | ConvertFrom-Json)
$manifest = [ordered]@{
schemaVersion = 1
tag = [string]$context.tag
releaseKind = "preview"
buildId = [int]$context.buildId
definitionId = [int]$context.definitionId
buildUrl = [string]$context.buildUrl
buildIntent = [string]$context.intent
buildChannel = [string]$context.channel
buildShouldPublishPreview = [bool]$context.shouldPublishPreview
sourceBranch = [string]$context.sourceBranch
sourceCommit = [string]$context.sourceCommit
previousReleaseTag = [string]$baseline.tag
previousSourceBranch = if ($baseline.sourceBranch) { [string]$baseline.sourceBranch } else { $null }
previousSourceCommit = [string]$baseline.sourceCommit
deltaMode = [string]$commitDelta.deltaMode
mergeBase = if ($commitDelta.mergeBase) { [string]$commitDelta.mergeBase } else { $null }
addedPrNumbers = @($added | ForEach-Object { [int]$_.number })
removedPrNumbers = @($removed | ForEach-Object { [int]$_.number })
unattributedCommits = @($unattributed | ForEach-Object {
[ordered]@{
sha = [string]$_.sha
subject = [string]$_.subject
}
})
generatedAt = (Get-Date).ToUniversalTime().ToString("o")
}
$parent = Split-Path -Parent $OutputPath
if ($parent) {
New-Item -ItemType Directory -Path $parent -Force | Out-Null
}
$manifest | ConvertTo-Json -Depth 7 | Set-Content -LiteralPath $OutputPath -Encoding utf8
[pscustomobject]$manifest

View File

@@ -6,11 +6,10 @@
"Installer Hashes" markdown table.
.DESCRIPTION
Given an ADO Dart pipeline build id (e.g. from
https://microsoft.visualstudio.com/Dart/_build/results?buildId=NNN),
downloads the four installer EXEs and the per-arch symbol zips into a
single per-version folder, then writes a hashes.md alongside them with a
markdown table ready to paste into the GitHub release notes.
Given an ADO Dart pipeline build id, downloads and validates the four
installer EXEs, GPO archive, and per-architecture symbol zips. The script
validates installer signatures, ADO-published hashes, ZIP integrity, and
GPO contents, then writes hashes.md and assets-manifest.json.
Requires: az login (Azure CLI authenticated), az devops extension.
@@ -22,17 +21,32 @@ param(
[Parameter(Mandatory = $true)]
[int]$BuildId,
[string]$Version,
[string]$BuildMetadataPath,
[string]$OutputFolder = "$env:USERPROFILE\Downloads",
[string]$DestinationFolder,
[string]$Organization = "https://dev.azure.com/microsoft",
[string]$Project = "Dart",
[string]$GitHubRepo = "microsoft/PowerToys"
[string]$GitHubRepo = "microsoft/PowerToys",
[ValidateRange(1, 20)]
[int]$DownloadMaxAttempts = 3,
[ValidateRange(0, 300)]
[int]$DownloadRetryDelaySeconds = 10
)
$ErrorActionPreference = "Stop"
$env:AZURE_CORE_NO_PROMPT = "true"
. (Join-Path $PSScriptRoot "web-response-content.ps1")
. (Join-Path $PSScriptRoot "preview-release-assets.ps1")
# --- Helpers -----------------------------------------------------------------
# Invoke an `az` CLI command and capture stderr in $script:LastAzError so
@@ -85,11 +99,10 @@ function Invoke-AdoDownload {
param(
[Parameter(Mandatory)][string]$Url,
[Parameter(Mandatory)][string]$DestPath,
[Parameter(Mandatory)][string]$Token,
[int]$MaxAttempts = 3
[Parameter(Mandatory)][string]$Token
)
$lastError = $null
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
for ($attempt = 1; $attempt -le $DownloadMaxAttempts; $attempt++) {
$webClient = New-Object System.Net.WebClient
$webClient.Headers.Add("Authorization", "Bearer $Token")
try {
@@ -101,8 +114,13 @@ function Invoke-AdoDownload {
if (Test-Path $DestPath) {
Remove-Item $DestPath -Force -ErrorAction SilentlyContinue
}
if ($attempt -lt $MaxAttempts) {
$backoffSec = [int][Math]::Pow(2, $attempt) # 2, 4, 8 ...
if ($attempt -lt $DownloadMaxAttempts) {
$backoffSec = if ($DownloadRetryDelaySeconds -gt 0) {
$DownloadRetryDelaySeconds
}
else {
[int][Math]::Pow(2, $attempt)
}
Write-Host " Attempt $attempt failed: $($_.Exception.Message). Retrying in ${backoffSec}s..." -ForegroundColor Yellow
Start-Sleep -Seconds $backoffSec
}
@@ -111,7 +129,31 @@ function Invoke-AdoDownload {
$webClient.Dispose()
}
}
throw "Download failed after $MaxAttempts attempts. Last error: $($lastError.Exception.Message)`nURL: $Url"
throw "Download failed after $DownloadMaxAttempts attempts. Last error: $($lastError.Exception.Message)`nURL: $Url"
}
function Get-RemoteHash {
param(
[Parameter(Mandatory)]$Artifact,
[Parameter(Mandatory)][string]$HashFile,
[Parameter(Mandatory)][string]$Token
)
$url = Get-ArtifactDownloadUrl -BaseUrl $Artifact.resource.downloadUrl -SubPath "/$HashFile" -Format file
try {
$response = Invoke-WebRequest `
-Uri $url `
-Headers @{ Authorization = "Bearer $Token" } `
-TimeoutSec 30
$text = ConvertFrom-WebResponseContent -Content $response.Content
if ($text -match "[0-9a-fA-F]{64}") {
return $matches[0].ToUpperInvariant()
}
throw "Hash file '$HashFile' does not contain a valid SHA256 hash."
}
catch {
throw "Failed to load required ADO hash '$HashFile' from artifact '$($Artifact.name)'. $_"
}
}
# -----------------------------------------------------------------------------
@@ -158,10 +200,27 @@ if (-not $buildJson) {
}
$build = $buildJson | ConvertFrom-Json
$versionParam = $build.templateParameters.VersionNumber
$versionParam = $Version
if (-not $versionParam -and $BuildMetadataPath) {
$versionParam = [string](Get-Content -LiteralPath $BuildMetadataPath -Raw | ConvertFrom-Json).version
}
if (-not $versionParam) {
Write-Error "Could not determine version from build $BuildId"
exit 1
$versionParam = [string]$build.templateParameters.VersionNumber
}
if (-not $versionParam) {
$metadataResolver = Join-Path $PSScriptRoot "get-release-build-metadata.ps1"
try {
$versionParam = [string](& $metadataResolver `
-Build $BuildId `
-Organization $Organization `
-Project $Project).version
}
catch {
throw "Could not determine version from build $BuildId. Pipeline metadata or an explicit -Version is required. $_"
}
}
if ($versionParam -notmatch "^\d+\.\d+\.\d+\.0$") {
throw "Resolved version '$versionParam' is not a valid four-component PowerToys version."
}
Write-Host " Version: $versionParam" -ForegroundColor DarkGray
@@ -175,12 +234,23 @@ if (-not $artifactsJson) {
$artifacts = $artifactsJson | ConvertFrom-Json
# --- Step 3: Prepare destination folder ---
$destFolder = Join-Path $OutputFolder "PowerToys-v$versionParam"
$destFolder = if ($DestinationFolder) {
$DestinationFolder
}
else {
Join-Path $OutputFolder "PowerToys-v$versionParam"
}
if (-not (Test-Path $destFolder)) {
New-Item -ItemType Directory -Path $destFolder -Force | Out-Null
}
Write-Host " Destination: $destFolder" -ForegroundColor DarkGray
$buildMarkerPath = Join-Path $destFolder ".buildinfo.json"
$sameBuild = Test-PreviewReleaseAssetBuildMarker `
-MarkerPath $buildMarkerPath `
-BuildId $BuildId `
-Version $versionParam
# --- Step 4: Get an ADO access token once ---
$token = Invoke-Az account get-access-token --resource "499b84ac-1321-427f-aa17-267ca6975798" --query accessToken -o tsv
if (-not $token) {
@@ -190,28 +260,34 @@ if (-not $token) {
# --- Step 5: Define the four installers to download ---
$targets = @(
[pscustomobject]@{ Description = "Per user - x64"; Scope = "perUser"; Arch = "x64"; Artifact = "build-x64-Release"; FileName = "PowerToysUserSetup-$versionParam-x64.exe" }
[pscustomobject]@{ Description = "Per user - ARM64"; Scope = "perUser"; Arch = "arm64"; Artifact = "build-arm64-Release"; FileName = "PowerToysUserSetup-$versionParam-arm64.exe" }
[pscustomobject]@{ Description = "Machine wide - x64"; Scope = "perMachine"; Arch = "x64"; Artifact = "build-x64-Release"; FileName = "PowerToysSetup-$versionParam-x64.exe" }
[pscustomobject]@{ Description = "Machine wide - ARM64"; Scope = "perMachine"; Arch = "arm64"; Artifact = "build-arm64-Release"; FileName = "PowerToysSetup-$versionParam-arm64.exe" }
[pscustomobject]@{ Description = "Per user - x64"; Scope = "perUser"; Arch = "x64"; Artifact = "build-x64-Release"; FileName = "PowerToysUserSetup-$versionParam-x64.exe"; Ref = "ptUserX64"; HashFile = "hash_user_x64.txt" }
[pscustomobject]@{ Description = "Per user - ARM64"; Scope = "perUser"; Arch = "arm64"; Artifact = "build-arm64-Release"; FileName = "PowerToysUserSetup-$versionParam-arm64.exe"; Ref = "ptUserArm64"; HashFile = "hash_user_arm64.txt" }
[pscustomobject]@{ Description = "Machine wide - x64"; Scope = "perMachine"; Arch = "x64"; Artifact = "build-x64-Release"; FileName = "PowerToysSetup-$versionParam-x64.exe"; Ref = "ptMachineX64"; HashFile = "hash_machine_x64.txt" }
[pscustomobject]@{ Description = "Machine wide - ARM64"; Scope = "perMachine"; Arch = "arm64"; Artifact = "build-arm64-Release"; FileName = "PowerToysSetup-$versionParam-arm64.exe"; Ref = "ptMachineArm64"; HashFile = "hash_machine_arm64.txt" }
)
# --- Step 6: Download each installer (skip if already present) ---
foreach ($t in $targets) {
$destPath = Join-Path $destFolder $t.FileName
if (Test-Path $destPath) {
$sizeMB = [math]::Round((Get-Item $destPath).Length / 1MB, 1)
Write-Host "[skip] $($t.FileName) already exists ($sizeMB MB)" -ForegroundColor DarkGray
continue
}
$artifact = $artifacts | Where-Object { $_.name -eq $t.Artifact }
if (-not $artifact) {
Write-Error "Artifact '$($t.Artifact)' not found in build $BuildId. Available: $(($artifacts | ForEach-Object name) -join ', ')"
exit 1
}
if (Test-Path $destPath) {
$sizeMB = [math]::Round((Get-Item $destPath).Length / 1MB, 1)
$remoteHash = Get-RemoteHash -Artifact $artifact -HashFile $t.HashFile -Token $token
$localHash = (Get-FileHash -LiteralPath $destPath -Algorithm SHA256).Hash.ToUpperInvariant()
if ($localHash -eq $remoteHash) {
Write-Host "[skip] $($t.FileName) already matches build $BuildId ($sizeMB MB)" -ForegroundColor DarkGray
continue
}
Write-Host "[update] $($t.FileName) cannot be verified against build $BuildId" -ForegroundColor Yellow
Remove-Item -LiteralPath $destPath -Force
}
$fileUrl = Get-ArtifactDownloadUrl -BaseUrl $artifact.resource.downloadUrl -SubPath "/$($t.FileName)" -Format file
Write-Host "Downloading $($t.FileName) ..." -ForegroundColor Cyan
@@ -227,6 +303,31 @@ foreach ($t in $targets) {
Write-Host " Saved ($sizeMB MB)" -ForegroundColor Green
}
# --- Step 6a: Download Group Policy archive ---
$gpoFileName = "GroupPolicyObjectFiles-$versionParam.zip"
$gpoPath = Join-Path $destFolder $gpoFileName
$gpoArtifact = $artifacts | Where-Object { $_.name -eq "build-x64-Release" }
if (-not $gpoArtifact) {
throw "Artifact 'build-x64-Release' is required for the GPO archive."
}
if ((Test-Path -LiteralPath $gpoPath) -and -not $sameBuild) {
Remove-Item -LiteralPath $gpoPath -Force
}
elseif (Test-Path -LiteralPath $gpoPath) {
try {
Assert-PreviewReleaseZipReadable -Path $gpoPath | Out-Null
}
catch {
Write-Host "[update] $gpoFileName is corrupt and will be downloaded again" -ForegroundColor Yellow
Remove-Item -LiteralPath $gpoPath -Force
}
}
if (-not (Test-Path -LiteralPath $gpoPath)) {
$gpoUrl = Get-ArtifactDownloadUrl -BaseUrl $gpoArtifact.resource.downloadUrl -SubPath "/$gpoFileName" -Format file
Write-Host "Downloading $gpoFileName ..." -ForegroundColor Cyan
Invoke-AdoDownload -Url $gpoUrl -DestPath $gpoPath -Token $token
}
# --- Step 6b: Download symbols (one zip per arch) ---
$symbolTargets = @(
[pscustomobject]@{ Arch = "x64"; Artifact = "build-x64-Release"; SubPath = "/symbols-x64" }
@@ -235,6 +336,18 @@ $symbolTargets = @(
foreach ($s in $symbolTargets) {
$finalZip = Join-Path $destFolder "symbols-$($s.Arch).zip"
if ((Test-Path $finalZip) -and -not $sameBuild) {
Remove-Item -LiteralPath $finalZip -Force
}
elseif (Test-Path -LiteralPath $finalZip) {
try {
Assert-PreviewReleaseZipReadable -Path $finalZip | Out-Null
}
catch {
Write-Host "[update] symbols-$($s.Arch).zip is corrupt and will be downloaded again" -ForegroundColor Yellow
Remove-Item -LiteralPath $finalZip -Force
}
}
if (Test-Path $finalZip) {
$sizeMB = [math]::Round((Get-Item $finalZip).Length / 1MB, 1)
Write-Host "[skip] symbols-$($s.Arch).zip already exists ($sizeMB MB)" -ForegroundColor DarkGray
@@ -307,9 +420,72 @@ foreach ($s in $symbolTargets) {
}
}
# --- Step 7: Compute SHA256 and build markdown ---
Write-Host "`nComputing SHA256 hashes..." -ForegroundColor Cyan
# --- Step 7: Validate and inventory all release assets ---
Write-Host "`nValidating release assets..." -ForegroundColor Cyan
$assetManifestItems = @()
foreach ($t in $targets) {
$path = Join-Path $destFolder $t.FileName
$file = Get-Item -LiteralPath $path
if ($file.Length -le 0) {
throw "Installer '$($file.Name)' is empty."
}
$hash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToUpperInvariant()
$artifact = $artifacts | Where-Object { $_.name -eq $t.Artifact }
$remoteHash = Get-RemoteHash -Artifact $artifact -HashFile $t.HashFile -Token $token
if ($hash -ne $remoteHash) {
throw "Installer '$($file.Name)' hash '$hash' does not match ADO-published hash '$remoteHash'."
}
$signature = Get-AuthenticodeSignature -LiteralPath $path
if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) {
throw "Installer '$($file.Name)' has invalid Authenticode status '$($signature.Status)'."
}
if (-not $signature.SignerCertificate -or
$signature.SignerCertificate.Subject -notmatch "(^|,\s*)CN=Microsoft Corporation(,|$)") {
throw "Installer '$($file.Name)' is not signed by Microsoft Corporation."
}
$assetManifestItems += [pscustomobject]@{
name = $file.Name
size = [long]$file.Length
sha256 = $hash
adoSha256 = $remoteHash
signature = "valid"
signer = [string]$signature.SignerCertificate.Subject
architecture = $t.Arch
scope = $t.Scope
}
}
$gpoEntries = @(Assert-PreviewReleaseZipReadable -Path $gpoPath)
if (-not ($gpoEntries | Where-Object { $_ -match "(^|/)PowerToys\.admx$" })) {
throw "GPO archive '$gpoFileName' does not contain PowerToys.admx."
}
if (-not ($gpoEntries | Where-Object { $_ -match "(^|/)en-US/PowerToys\.adml$" })) {
throw "GPO archive '$gpoFileName' does not contain en-US/PowerToys.adml."
}
foreach ($zipName in @($gpoFileName, "symbols-x64.zip", "symbols-arm64.zip")) {
$path = Join-Path $destFolder $zipName
$entries = @(Assert-PreviewReleaseZipReadable -Path $path)
$file = Get-Item -LiteralPath $path
if ($file.Length -le 0) {
throw "Archive '$zipName' is empty."
}
$assetManifestItems += [pscustomobject]@{
name = $file.Name
size = [long]$file.Length
sha256 = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToUpperInvariant()
signature = $null
entries = $entries.Count
}
}
# --- Step 8: Build the installer hash markdown and manifests ---
$releaseTag = "v$versionParam"
$releaseBaseUrl = "https://github.com/$GitHubRepo/releases/download/$releaseTag"
$sb = [System.Text.StringBuilder]::new()
[void]$sb.AppendLine("## Installer Hashes")
[void]$sb.AppendLine("")
@@ -317,18 +493,43 @@ $sb = [System.Text.StringBuilder]::new()
[void]$sb.AppendLine("| --- | --- | --- |")
foreach ($t in $targets) {
$destPath = Join-Path $destFolder $t.FileName
$hash = (Get-FileHash -Path $destPath -Algorithm SHA256).Hash.ToUpper()
[void]$sb.AppendLine("| $($t.Description) | $($t.FileName) | $hash |")
Write-Host " $($t.FileName) $hash" -ForegroundColor DarkGray
$item = $assetManifestItems | Where-Object { $_.name -eq $t.FileName }
[void]$sb.AppendLine("| $($t.Description) | [$($t.FileName)][$($t.Ref)] | $($item.sha256) |")
}
[void]$sb.AppendLine("")
foreach ($t in $targets) {
[void]$sb.AppendLine("[$($t.Ref)]: $releaseBaseUrl/$($t.FileName)")
}
$markdown = $sb.ToString()
$mdPath = Join-Path $destFolder "hashes.md"
Set-Content -Path $mdPath -Value $markdown -Encoding UTF8
Set-Content -LiteralPath $mdPath -Value $markdown -Encoding utf8
Write-Host "`nMarkdown written to: $mdPath" -ForegroundColor Green
Write-Host "`n----- Installer Hashes -----`n" -ForegroundColor Yellow
Write-Host $markdown
$assetsManifestPath = Join-Path $destFolder "assets-manifest.json"
[ordered]@{
schemaVersion = 1
buildId = $BuildId
version = $versionParam
generatedAt = (Get-Date).ToUniversalTime().ToString("o")
assets = $assetManifestItems
} | ConvertTo-Json -Depth 7 | Set-Content -LiteralPath $assetsManifestPath -Encoding utf8
Write-Host "Draft a new GitHub release at: https://github.com/$GitHubRepo/releases/new?tag=v$versionParam" -ForegroundColor Green
[ordered]@{
schemaVersion = 1
buildId = $BuildId
version = $versionParam
updatedAt = (Get-Date).ToUniversalTime().ToString("o")
} | ConvertTo-Json | Set-Content -LiteralPath $buildMarkerPath -Encoding utf8
Write-Host "`nAll release assets passed validation." -ForegroundColor Green
Write-Host " Hashes: $mdPath" -ForegroundColor DarkGray
Write-Host " Manifest: $assetsManifestPath" -ForegroundColor DarkGray
[pscustomobject]@{
buildId = $BuildId
version = $versionParam
destinationFolder = (Resolve-Path -LiteralPath $destFolder).Path
hashesPath = (Resolve-Path -LiteralPath $mdPath).Path
assetsManifestPath = (Resolve-Path -LiteralPath $assetsManifestPath).Path
assetCount = $assetManifestItems.Count
}

View File

@@ -0,0 +1,125 @@
function Test-PreviewReleaseAssetBuildMarker {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$MarkerPath,
[Parameter(Mandatory)][int]$BuildId,
[Parameter(Mandatory)][string]$Version
)
if (-not (Test-Path -LiteralPath $MarkerPath -PathType Leaf)) {
return $false
}
$marker = Get-Content -LiteralPath $MarkerPath -Raw | ConvertFrom-Json
return [int]$marker.buildId -eq $BuildId -and [string]$marker.version -eq $Version
}
function Assert-PreviewReleaseZipReadable {
[CmdletBinding()]
param([Parameter(Mandatory)][string]$Path)
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive = [System.IO.Compression.ZipFile]::OpenRead($Path)
try {
if ($archive.Entries.Count -eq 0) {
throw "ZIP archive '$Path' is empty."
}
$buffer = [byte[]]::new(81920)
foreach ($entry in $archive.Entries) {
if ([string]::IsNullOrEmpty($entry.Name)) {
continue
}
$stream = $entry.Open()
try {
while ($stream.Read($buffer, 0, $buffer.Length) -gt 0) {
}
}
finally {
$stream.Dispose()
}
}
return @($archive.Entries | ForEach-Object { $_.FullName.Replace("\", "/") })
}
finally {
$archive.Dispose()
}
}
function Get-PreviewReleaseAssets {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$AssetsDirectory
)
$manifestPath = Join-Path $AssetsDirectory "assets-manifest.json"
if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) {
throw "Release asset manifest not found: $manifestPath"
}
try {
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
}
catch {
throw "Release asset manifest '$manifestPath' is invalid JSON. $_"
}
if ([int]$manifest.schemaVersion -ne 1) {
throw "Release asset manifest '$manifestPath' has unsupported schema version '$($manifest.schemaVersion)'."
}
$items = @($manifest.assets)
if ($items.Count -eq 0) {
throw "Release asset manifest '$manifestPath' does not declare any assets."
}
$declaredNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
$files = @()
foreach ($item in $items) {
$name = [string]$item.name
if ([string]::IsNullOrWhiteSpace($name) -or
[System.IO.Path]::GetFileName($name) -ne $name -or
[System.IO.Path]::GetExtension($name) -notin @(".exe", ".zip")) {
throw "Release asset manifest contains invalid asset name '$name'."
}
if (-not $declaredNames.Add($name)) {
throw "Release asset manifest contains duplicate asset name '$name'."
}
$path = Join-Path $AssetsDirectory $name
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
throw "Manifest-declared release asset not found: $path"
}
$file = Get-Item -LiteralPath $path
if ([long]$item.size -ne [long]$file.Length) {
throw "Release asset '$name' size '$($file.Length)' does not match manifest size '$($item.size)'."
}
$expectedHash = [string]$item.sha256
if ($expectedHash -notmatch "^[0-9a-fA-F]{64}$") {
throw "Release asset '$name' has an invalid SHA256 value in assets-manifest.json."
}
$actualHash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash
if ($actualHash -ne $expectedHash) {
throw "Release asset '$name' SHA256 '$actualHash' does not match manifest SHA256 '$expectedHash'."
}
$files += $file
}
$extraCandidates = @(
Get-ChildItem -LiteralPath $AssetsDirectory -File |
Where-Object {
$_.Extension -in @(".exe", ".zip") -and
-not $declaredNames.Contains($_.Name)
}
)
if ($extraCandidates.Count -gt 0) {
throw "Assets directory contains undeclared release files: $(($extraCandidates.Name | Sort-Object) -join ', ')"
}
return @($files | Sort-Object FullName -Unique)
}

View File

@@ -0,0 +1,232 @@
<#
.SYNOPSIS
Creates or updates a PowerToys GitHub draft preview release.
.DESCRIPTION
This script intentionally exposes no publish operation. It preserves text
outside the managed preview-agent body markers and uploads only explicitly
generated release assets.
.EXAMPLE
.\upsert-draft-preview-release.ps1 -Tag v0.101.2181.0 -TargetCommit 0123... -BodyPath .\release-notes.md -AssetsDirectory .\assets
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Tag,
[Parameter(Mandatory)][string]$TargetCommit,
[Parameter(Mandatory)][string]$BodyPath,
[Parameter(Mandatory)][string]$AssetsDirectory,
[string]$Repo = "microsoft/PowerToys",
[string]$OutputPath,
[string]$ExistingReleaseJsonPath,
[string]$MergedBodyOutputPath,
[switch]$DryRun
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
. (Join-Path $PSScriptRoot "preview-release-assets.ps1")
. (Join-Path $PSScriptRoot "github-tag-target.ps1")
$beginMarker = "<!-- BEGIN POWERTOYS PREVIEW AGENT -->"
$endMarker = "<!-- END POWERTOYS PREVIEW AGENT -->"
$releaseTitle = "Preview $Tag"
function Get-ManagedBlock {
param([Parameter(Mandatory)][string]$Body)
$start = $Body.IndexOf($beginMarker, [StringComparison]::Ordinal)
$end = $Body.IndexOf($endMarker, [StringComparison]::Ordinal)
if ($start -ge 0 -and $end -gt $start) {
return $Body.Substring($start, ($end + $endMarker.Length) - $start)
}
return "$beginMarker`n$($Body.Trim())`n$endMarker"
}
function Merge-ReleaseBody {
param(
[string]$ExistingBody,
[Parameter(Mandatory)][string]$GeneratedBody
)
$managedBlock = Get-ManagedBlock -Body $GeneratedBody
if ([string]::IsNullOrWhiteSpace($ExistingBody)) {
return $managedBlock
}
$start = $ExistingBody.IndexOf($beginMarker, [StringComparison]::Ordinal)
$end = $ExistingBody.IndexOf($endMarker, [StringComparison]::Ordinal)
if ($start -ge 0 -and $end -gt $start) {
$prefix = $ExistingBody.Substring(0, $start)
$suffix = $ExistingBody.Substring($end + $endMarker.Length)
return "$prefix$managedBlock$suffix"
}
return "$($ExistingBody.TrimEnd())`n`n$managedBlock"
}
if ($TargetCommit -notmatch "^[0-9a-fA-F]{40}$") {
throw "TargetCommit must be a full immutable commit SHA."
}
if (-not (Test-Path -LiteralPath $BodyPath -PathType Leaf)) {
throw "Release body not found: $BodyPath"
}
if (-not (Test-Path -LiteralPath $AssetsDirectory -PathType Container)) {
throw "Assets directory not found: $AssetsDirectory"
}
$generatedBody = Get-Content -LiteralPath $BodyPath -Raw
$assetFiles = @(Get-PreviewReleaseAssets -AssetsDirectory $AssetsDirectory)
if ($assetFiles.Count -eq 0) {
throw "No generated release assets were found in '$AssetsDirectory'."
}
foreach ($asset in $assetFiles) {
if ($asset.Length -le 0) {
throw "Release asset '$($asset.FullName)' is empty."
}
}
$existing = $null
if ($ExistingReleaseJsonPath) {
$existing = Get-Content -LiteralPath $ExistingReleaseJsonPath -Raw | ConvertFrom-Json
}
elseif (-not $DryRun) {
if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
throw "GitHub CLI ('gh') is required. Install it and run 'gh auth login'."
}
gh auth status | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "GitHub CLI is not authenticated."
}
$existingJson = gh release view $Tag `
--repo $Repo `
--json databaseId,isDraft,isPrerelease,tagName,targetCommitish,url,body 2>$null
if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($existingJson)) {
$existing = $existingJson | ConvertFrom-Json
}
}
if ($existing -and -not [bool]$existing.isDraft) {
throw "Published release '$Tag' already exists. Published releases are immutable in this workflow."
}
if (-not $DryRun) {
$tagCommit = Get-GitHubTagCommit -Repo $Repo -Tag $Tag
Assert-GitHubTagTarget -Tag $Tag -ResolvedCommit $tagCommit -TargetCommit $TargetCommit
}
$finalBody = Merge-ReleaseBody `
-ExistingBody $(if ($existing) { [string]$existing.body } else { "" }) `
-GeneratedBody $generatedBody
if ($MergedBodyOutputPath) {
$mergedParent = Split-Path -Parent $MergedBodyOutputPath
if ($mergedParent) {
New-Item -ItemType Directory -Path $mergedParent -Force | Out-Null
}
Set-Content -LiteralPath $MergedBodyOutputPath -Value $finalBody -Encoding utf8
}
$temporaryBody = [System.IO.Path]::GetTempFileName()
try {
Set-Content -LiteralPath $temporaryBody -Value $finalBody -Encoding utf8
if (-not $DryRun) {
if ($existing) {
gh release edit $Tag `
--repo $Repo `
--title $releaseTitle `
--notes-file $temporaryBody `
--target $TargetCommit `
--draft `
--prerelease | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Failed to update draft release '$Tag'."
}
}
else {
gh release create $Tag `
--repo $Repo `
--title $releaseTitle `
--notes-file $temporaryBody `
--target $TargetCommit `
--draft `
--prerelease | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Failed to create draft release '$Tag'."
}
}
$releaseMetadataJson = gh release view $Tag `
--repo $Repo `
--json databaseId
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($releaseMetadataJson)) {
throw "Failed to load draft '$Tag' before asset upload."
}
$releaseMetadata = $releaseMetadataJson | ConvertFrom-Json
$remoteReleaseJson = gh api "repos/$Repo/releases/$($releaseMetadata.databaseId)"
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($remoteReleaseJson)) {
throw "Failed to inspect existing assets for draft '$Tag'."
}
$remoteRelease = $remoteReleaseJson | ConvertFrom-Json
$localOnlyManifestNames = @("release-manifest.json", "assets-manifest.json")
$staleManifests = @($remoteRelease.assets | Where-Object { $_.name -in $localOnlyManifestNames })
foreach ($asset in $staleManifests) {
gh api --method DELETE "repos/$Repo/releases/assets/$($asset.id)" | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Failed to remove stale local-only manifest '$($asset.name)' from draft '$Tag'."
}
}
$assetPaths = @($assetFiles | ForEach-Object { $_.FullName })
& gh release upload $Tag --repo $Repo --clobber @assetPaths
if ($LASTEXITCODE -ne 0) {
throw "Draft '$Tag' exists, but one or more generated assets failed to upload."
}
$verifiedJson = gh release view $Tag `
--repo $Repo `
--json isDraft,isPrerelease,targetCommitish,url,name
if ($LASTEXITCODE -ne 0) {
throw "Failed to reload draft '$Tag' after update."
}
$verified = $verifiedJson | ConvertFrom-Json
if (-not [bool]$verified.isDraft -or -not [bool]$verified.isPrerelease) {
throw "Release '$Tag' failed the post-write draft/prerelease safety assertion."
}
if ([string]$verified.targetCommitish -ne $TargetCommit) {
throw "Release '$Tag' target '$($verified.targetCommitish)' does not match '$TargetCommit'."
}
if ([string]$verified.name -ne $releaseTitle) {
throw "Release '$Tag' title '$($verified.name)' does not match '$releaseTitle'."
}
}
$result = [ordered]@{
schemaVersion = 1
dryRun = [bool]$DryRun
action = if ($existing) { "updated" } else { "created" }
tag = $Tag
title = $releaseTitle
targetCommit = $TargetCommit.ToLowerInvariant()
draft = $true
prerelease = $true
url = if ($DryRun) { $null } else { [string]$verified.url }
assetNames = @($assetFiles | ForEach-Object { $_.Name })
}
if ($OutputPath) {
$parent = Split-Path -Parent $OutputPath
if ($parent) {
New-Item -ItemType Directory -Path $parent -Force | Out-Null
}
$result | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $OutputPath -Encoding utf8
}
[pscustomobject]$result
}
finally {
Remove-Item -LiteralPath $temporaryBody -Force -ErrorAction SilentlyContinue
}

View File

@@ -0,0 +1,234 @@
<#
.SYNOPSIS
Verifies a PowerToys draft preview release and writes the final review report.
.DESCRIPTION
Asserts immutable target identity, draft/prerelease flags, managed body
markers, and exact asset names and sizes. This script performs no writes to
GitHub.
.EXAMPLE
.\verify-draft-preview-release.ps1 -Tag v0.101.2181.0 -TargetCommit 0123... -AssetsDirectory .\assets -OutputPath .\final-review.md
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Tag,
[Parameter(Mandatory)][string]$TargetCommit,
[Parameter(Mandatory)][string]$AssetsDirectory,
[string]$Repo = "microsoft/PowerToys",
[string]$ContextPath,
[string]$PreviousReleasePath,
[string]$DeltaDirectory,
[string]$BodyPath,
[switch]$DryRun,
[Parameter(Mandatory)][string]$OutputPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
. (Join-Path $PSScriptRoot "preview-release-assets.ps1")
. (Join-Path $PSScriptRoot "github-tag-target.ps1")
if ($TargetCommit -notmatch "^[0-9a-fA-F]{40}$") {
throw "TargetCommit must be a full immutable commit SHA."
}
$release = if ($DryRun) {
if (-not $BodyPath -or -not (Test-Path -LiteralPath $BodyPath -PathType Leaf)) {
throw "Dry-run verification requires an existing -BodyPath."
}
[pscustomobject]@{
databaseId = $null
isDraft = $true
isPrerelease = $true
tagName = $Tag
targetCommitish = $TargetCommit
url = $null
body = Get-Content -LiteralPath $BodyPath -Raw
name = "Preview $Tag"
}
}
else {
if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
throw "GitHub CLI ('gh') is required. Install it and run 'gh auth login'."
}
$releaseJson = gh release view $Tag `
--repo $Repo `
--json databaseId,isDraft,isPrerelease,tagName,targetCommitish,url,body,name
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($releaseJson)) {
throw "Draft release '$Tag' was not found."
}
$releaseJson | ConvertFrom-Json
}
if (-not [bool]$release.isDraft) {
throw "Release '$Tag' is not a draft."
}
if (-not [bool]$release.isPrerelease) {
throw "Release '$Tag' is not marked as a prerelease."
}
if ([string]$release.targetCommitish -ne $TargetCommit) {
throw "Release target '$($release.targetCommitish)' does not match '$TargetCommit'."
}
if (-not $DryRun) {
$tagCommit = Get-GitHubTagCommit -Repo $Repo -Tag $Tag
Assert-GitHubTagTarget -Tag $Tag -ResolvedCommit $tagCommit -TargetCommit $TargetCommit
}
if ([string]$release.name -ne "Preview $Tag") {
throw "Release title '$($release.name)' does not match 'Preview $Tag'."
}
if ([string]$release.body -notmatch "<!-- BEGIN POWERTOYS PREVIEW AGENT -->" -or
[string]$release.body -notmatch "<!-- END POWERTOYS PREVIEW AGENT -->") {
throw "Release '$Tag' is missing the managed preview body markers."
}
$localFiles = @(Get-PreviewReleaseAssets -AssetsDirectory $AssetsDirectory)
$assetResults = @()
if ($DryRun) {
foreach ($file in $localFiles) {
$assetResults += [pscustomobject]@{
name = $file.Name
size = [long]$file.Length
state = "local"
}
}
}
else {
$apiJson = gh api "repos/$Repo/releases/$($release.databaseId)"
if ($LASTEXITCODE -ne 0) {
throw "Failed to load release assets for '$Tag'."
}
$apiRelease = $apiJson | ConvertFrom-Json
$remoteAssets = @($apiRelease.assets)
$uploadedLocalOnlyManifests = @(
$remoteAssets |
Where-Object { $_.name -in @("release-manifest.json", "assets-manifest.json") }
)
if ($uploadedLocalOnlyManifests.Count -ne 0) {
throw "Draft '$Tag' must not contain local-only manifests: $(($uploadedLocalOnlyManifests.name | Sort-Object) -join ', ')."
}
$expectedNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($file in $localFiles) {
[void]$expectedNames.Add($file.Name)
}
$unexpectedRemoteAssets = @(
$remoteAssets |
Where-Object {
[System.IO.Path]::GetExtension([string]$_.name) -in @(".exe", ".zip") -and
-not $expectedNames.Contains([string]$_.name)
}
)
if ($unexpectedRemoteAssets.Count -gt 0) {
throw "Draft '$Tag' contains unexpected generated assets: $(($unexpectedRemoteAssets.name | Sort-Object) -join ', ')"
}
foreach ($file in $localFiles) {
$remote = @($remoteAssets | Where-Object { $_.name -eq $file.Name })
if ($remote.Count -ne 1) {
throw "Expected exactly one uploaded asset named '$($file.Name)', found $($remote.Count)."
}
if ([long]$remote[0].size -ne [long]$file.Length) {
throw "Uploaded asset '$($file.Name)' size '$($remote[0].size)' does not match local size '$($file.Length)'."
}
$assetResults += [pscustomobject]@{
name = $file.Name
size = [long]$file.Length
state = [string]$remote[0].state
}
}
}
$context = if ($ContextPath) {
Get-Content -LiteralPath $ContextPath -Raw | ConvertFrom-Json
}
else {
$null
}
$baseline = if ($PreviousReleasePath) {
Get-Content -LiteralPath $PreviousReleasePath -Raw | ConvertFrom-Json
}
else {
$null
}
$added = @()
$removed = @()
$unattributed = @()
$deltaDetails = $null
if ($DeltaDirectory) {
$added = @(Get-Content -LiteralPath (Join-Path $DeltaDirectory "delta-prs.json") -Raw | ConvertFrom-Json)
$removed = @(Get-Content -LiteralPath (Join-Path $DeltaDirectory "removed-prs.json") -Raw | ConvertFrom-Json)
$unattributed = @(Get-Content -LiteralPath (Join-Path $DeltaDirectory "unattributed-commits.json") -Raw | ConvertFrom-Json)
$deltaDetails = Get-Content -LiteralPath (Join-Path $DeltaDirectory "delta-commits.json") -Raw | ConvertFrom-Json
}
$report = [System.Text.StringBuilder]::new()
[void]$report.AppendLine("# Preview release final review")
[void]$report.AppendLine("")
[void]$report.AppendLine($(if ($DryRun) {
"**PASS:** Local dry-run package is complete; no GitHub draft was created."
} else {
"**PASS:** Draft prerelease is complete and remains unpublished."
}))
[void]$report.AppendLine("")
[void]$report.AppendLine("- Draft: $(if ($DryRun) { "Not created (dry run)" } else { $release.url })")
[void]$report.AppendLine("- Title: $($release.name)")
if ($context) {
[void]$report.AppendLine("- Build: [$($context.buildId)]($($context.buildUrl))")
[void]$report.AppendLine("- Version: $($context.version)")
[void]$report.AppendLine("- Source: $($context.sourceBranch)@$(([string]$context.sourceCommit).Substring(0, 12))")
[void]$report.AppendLine("- Intent/channel: $($context.intent) / $($context.channel)")
}
if ($baseline) {
[void]$report.AppendLine("- Baseline: $($baseline.tag)@$(([string]$baseline.sourceCommit).Substring(0, 12))")
}
if ($deltaDetails) {
[void]$report.AppendLine("- Delta mode: $($deltaDetails.deltaMode)")
}
[void]$report.AppendLine("- Added PRs: $($added.Count)$(if ($added.Count) { " (" + (($added | ForEach-Object { "#$($_.number)" }) -join ", ") + ")" })")
[void]$report.AppendLine("- Removed PRs: $($removed.Count)$(if ($removed.Count) { " (" + (($removed | ForEach-Object { "#$($_.number)" }) -join ", ") + ")" })")
[void]$report.AppendLine("- Unattributed commits: $($unattributed.Count)")
[void]$report.AppendLine("- Assets: $($assetResults.Count)/$($localFiles.Count) verified")
[void]$report.AppendLine("")
[void]$report.AppendLine($(if ($DryRun) { "## Validated local assets" } else { "## Uploaded assets" }))
[void]$report.AppendLine("")
foreach ($asset in $assetResults) {
[void]$report.AppendLine("- $($asset.name) ($($asset.size) bytes)")
}
[void]$report.AppendLine("")
[void]$report.AppendLine("## Unattributed commits")
[void]$report.AppendLine("")
if ($unattributed.Count -eq 0) {
[void]$report.AppendLine("- None.")
}
else {
foreach ($commit in $unattributed) {
[void]$report.AppendLine("- `$($commit.sha)`: $($commit.subject)")
}
}
[void]$report.AppendLine("")
[void]$report.AppendLine("## Human review remaining")
[void]$report.AppendLine("")
[void]$report.AppendLine("- Review highlights, branch-transition removals, and unattributed changes.")
[void]$report.AppendLine($(if ($DryRun) {
"- Create the draft through the canonical release workflow before publication."
} else {
"- Download one installer and one ZIP from the draft."
}))
[void]$report.AppendLine("- Publish only through the existing release-management process.")
$parent = Split-Path -Parent $OutputPath
if ($parent) {
New-Item -ItemType Directory -Path $parent -Force | Out-Null
}
$report.ToString() | Set-Content -LiteralPath $OutputPath -Encoding utf8
[pscustomobject]@{
status = "PASS"
draftUrl = if ($DryRun) { $null } else { [string]$release.url }
assetCount = $assetResults.Count
outputPath = (Resolve-Path -LiteralPath $OutputPath).Path
}

View File

@@ -0,0 +1,15 @@
function ConvertFrom-WebResponseContent {
[CmdletBinding()]
param(
[AllowNull()]
$Content
)
if ($null -eq $Content) {
return ""
}
if ($Content -is [byte[]]) {
return [System.Text.Encoding]::UTF8.GetString($Content)
}
return [string]$Content
}

View File

@@ -0,0 +1,650 @@
# Copyright (c) Microsoft Corporation
# The Microsoft Corporation licenses this file to you under the MIT license.
$scripts = Join-Path $PSScriptRoot "..\scripts"
function Assert-Throws {
param([scriptblock]$Action)
$threw = $false
try {
& $Action | Out-Null
}
catch {
$threw = $true
}
$threw | Should Be $true
}
function Invoke-TestGit {
param(
[Parameter(Mandatory)][string]$Repository,
[Parameter(ValueFromRemainingArguments)][string[]]$Arguments
)
$output = & git -C $Repository @Arguments 2>&1
if ($LASTEXITCODE -ne 0) {
throw "git $($Arguments -join ' ') failed: $($output -join "`n")"
}
return $output
}
function Add-TestCommit {
param(
[Parameter(Mandatory)][string]$Repository,
[Parameter(Mandatory)][string]$FileName,
[Parameter(Mandatory)][string]$Content,
[Parameter(Mandatory)][string]$Message
)
Set-Content -LiteralPath (Join-Path $Repository $FileName) -Value $Content
Invoke-TestGit -Repository $Repository add $FileName | Out-Null
Invoke-TestGit -Repository $Repository commit -q -m $Message | Out-Null
return ([string](Invoke-TestGit -Repository $Repository rev-parse HEAD)).Trim()
}
function Write-TestAssetsManifest {
param(
[Parameter(Mandatory)][string]$AssetsPath,
[Parameter(Mandatory)][string[]]$AssetNames
)
$assets = @(
foreach ($name in $AssetNames) {
$path = Join-Path $AssetsPath $name
$file = Get-Item -LiteralPath $path
[ordered]@{
name = $file.Name
size = [long]$file.Length
sha256 = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash
}
}
)
[ordered]@{
schemaVersion = 1
assets = $assets
} | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $AssetsPath "assets-manifest.json")
}
Describe "web response content decoding" {
BeforeAll {
. (Join-Path $scripts "web-response-content.ps1")
}
It "decodes UTF-8 byte arrays returned by Invoke-WebRequest" {
$expected = "F74FF2A89EA37D582F7E18E34EA6E40554C842FA0405725F68969805E6DA0DA9"
$content = [System.Text.Encoding]::UTF8.GetBytes("$expected`r`n")
(ConvertFrom-WebResponseContent -Content $content).Trim() | Should Be $expected
}
It "preserves string response content" {
ConvertFrom-WebResponseContent -Content "response text" | Should Be "response text"
}
}
Describe "GitHub tag target validation" {
BeforeAll {
. (Join-Path $scripts "github-tag-target.ps1")
}
It "accepts an unused tag" {
Assert-GitHubTagTarget `
-Tag "v0.101.2181.0" `
-ResolvedCommit $null `
-TargetCommit "0123456789abcdef0123456789abcdef01234567"
}
It "accepts a tag that resolves to the target commit" {
Assert-GitHubTagTarget `
-Tag "v0.101.2181.0" `
-ResolvedCommit "0123456789abcdef0123456789abcdef01234567" `
-TargetCommit "0123456789abcdef0123456789abcdef01234567"
}
It "rejects a tag that resolves to another commit" {
Assert-Throws {
Assert-GitHubTagTarget `
-Tag "v0.101.2181.0" `
-ResolvedCommit "1123456789abcdef0123456789abcdef01234567" `
-TargetCommit "0123456789abcdef0123456789abcdef01234567"
}
}
}
Describe "preview release asset build marker" {
BeforeAll {
. (Join-Path $scripts "preview-release-assets.ps1")
}
It "matches the requested build and version" {
$markerPath = Join-Path $TestDrive "matching-build.json"
'{"buildId":154000000,"version":"0.101.2181.0"}' | Set-Content -LiteralPath $markerPath
Test-PreviewReleaseAssetBuildMarker `
-MarkerPath $markerPath `
-BuildId 154000000 `
-Version "0.101.2181.0" |
Should Be $true
}
It "rejects a marker for a different build" {
$markerPath = Join-Path $TestDrive "different-build.json"
'{"buildId":154000001,"version":"0.101.2181.0"}' | Set-Content -LiteralPath $markerPath
Test-PreviewReleaseAssetBuildMarker `
-MarkerPath $markerPath `
-BuildId 154000000 `
-Version "0.101.2181.0" |
Should Be $false
}
It "rejects a missing marker" {
Test-PreviewReleaseAssetBuildMarker `
-MarkerPath (Join-Path $TestDrive "missing-build.json") `
-BuildId 154000000 `
-Version "0.101.2181.0" |
Should Be $false
}
}
Describe "preview release ZIP validation" {
BeforeAll {
. (Join-Path $scripts "preview-release-assets.ps1")
}
It "reads valid entry payloads" {
$zipPath = Join-Path $TestDrive "valid.zip"
$filePath = Join-Path $TestDrive "payload.txt"
"PowerToys preview release payload" | Set-Content -LiteralPath $filePath
Compress-Archive -LiteralPath $filePath -DestinationPath $zipPath
$entries = @(Assert-PreviewReleaseZipReadable -Path $zipPath)
$entries.Count | Should Be 1
$entries[0] | Should Be "payload.txt"
}
It "rejects a corrupt compressed payload with an intact directory" {
$zipPath = Join-Path $TestDrive "corrupt.zip"
$filePath = Join-Path $TestDrive "corrupt-payload.txt"
("PowerToys preview release payload " * 100) | Set-Content -LiteralPath $filePath
Compress-Archive -LiteralPath $filePath -DestinationPath $zipPath
$bytes = [System.IO.File]::ReadAllBytes($zipPath)
$fileNameLength = [BitConverter]::ToUInt16($bytes, 26)
$extraLength = [BitConverter]::ToUInt16($bytes, 28)
$payloadOffset = 30 + $fileNameLength + $extraLength
$bytes[$payloadOffset] = 0xFF
[System.IO.File]::WriteAllBytes($zipPath, $bytes)
Assert-Throws {
Assert-PreviewReleaseZipReadable -Path $zipPath
}
}
}
Describe "preview release build metadata" {
It "supports a main-branch candidate regardless of release intent" {
$buildPath = Join-Path $TestDrive "build.json"
$metadataPath = Join-Path $TestDrive "release-metadata.json"
@'
{
"id": 154000000,
"definition": { "id": 76541 },
"buildNumber": "PowerToys Signed YAML Release Build_2608.06001-main",
"result": "succeeded",
"sourceBranch": "refs/heads/main",
"sourceVersion": "0123456789abcdef0123456789abcdef01234567",
"reason": "schedule",
"queueTime": "2026-08-06T06:00:00Z",
"startTime": "2026-08-06T06:00:20Z",
"finishTime": "2026-08-06T09:00:00Z",
"templateParameters": {}
}
'@ | Set-Content -LiteralPath $buildPath
@'
{
"schemaVersion": 1,
"definitionId": 76541,
"buildId": 154000000,
"version": "0.101.2181.0",
"channel": "preview",
"intent": "preview-release",
"sourceBranch": "refs/heads/main",
"sourceCommit": "0123456789abcdef0123456789abcdef01234567",
"shouldPublishPreview": true
}
'@ | Set-Content -LiteralPath $metadataPath
$result = & (Join-Path $scripts "get-release-build-metadata.ps1") `
-Build "https://microsoft.visualstudio.com/Dart/_build/results?buildId=154000000" `
-BuildJsonPath $buildPath `
-MetadataJsonPath $metadataPath
$result.buildId | Should Be 154000000
$result.version | Should Be "0.101.2181.0"
$result.intent | Should Be "preview-release"
@'
{
"schemaVersion": 1,
"definitionId": 76541,
"buildId": 154000000,
"version": "0.101.2181.0",
"channel": "preview",
"intent": "preview-validation",
"sourceBranch": "refs/heads/main",
"sourceCommit": "0123456789abcdef0123456789abcdef01234567",
"shouldPublishPreview": false
}
'@ | Set-Content -LiteralPath $metadataPath
$result = & (Join-Path $scripts "get-release-build-metadata.ps1") `
-Build "https://microsoft.visualstudio.com/Dart/_build/results?buildId=154000000" `
-BuildJsonPath $buildPath `
-MetadataJsonPath $metadataPath
$result.intent | Should Be "preview-validation"
$result.shouldPublishPreview | Should Be $false
}
It "supports a stable-branch candidate regardless of release intent" {
$buildPath = Join-Path $TestDrive "stable-build.json"
$metadataPath = Join-Path $TestDrive "stable-metadata.json"
@'
{
"id": 154000001,
"definition": { "id": 76541 },
"buildNumber": "stable",
"result": "succeeded",
"sourceBranch": "refs/heads/stable",
"sourceVersion": "1123456789abcdef0123456789abcdef01234567",
"reason": "manual",
"queueTime": "2026-08-06T06:00:00Z",
"startTime": "2026-08-06T06:00:20Z",
"finishTime": "2026-08-06T09:00:00Z",
"templateParameters": {}
}
'@ | Set-Content -LiteralPath $buildPath
@'
{
"version": "0.101.2181.0",
"channel": "stable",
"intent": "stable-release",
"shouldPublishPreview": false
}
'@ | Set-Content -LiteralPath $metadataPath
$result = & (Join-Path $scripts "get-release-build-metadata.ps1") `
-Build 154000001 `
-BuildJsonPath $buildPath `
-MetadataJsonPath $metadataPath
$result.intent | Should Be "stable-release"
$result.channel | Should Be "stable"
$result.shouldPublishPreview | Should Be $false
@'
{
"version": "0.101.2181.0",
"channel": "preview",
"intent": "stable-release",
"shouldPublishPreview": false
}
'@ | Set-Content -LiteralPath $metadataPath
$result = & (Join-Path $scripts "get-release-build-metadata.ps1") `
-Build 154000001 `
-BuildJsonPath $buildPath `
-MetadataJsonPath $metadataPath
$result.intent | Should Be "stable-release"
$result.channel | Should Be "preview"
}
It "rejects a release build from an unsupported branch" {
$buildPath = Join-Path $TestDrive "private-build.json"
$metadataPath = Join-Path $TestDrive "private-metadata.json"
@'
{
"id": 154000002,
"definition": { "id": 76541 },
"buildNumber": "private",
"result": "succeeded",
"sourceBranch": "refs/heads/user/feature",
"sourceVersion": "2123456789abcdef0123456789abcdef01234567",
"reason": "manual",
"queueTime": "2026-08-06T06:00:00Z",
"startTime": "2026-08-06T06:00:20Z",
"finishTime": "2026-08-06T09:00:00Z",
"templateParameters": {}
}
'@ | Set-Content -LiteralPath $buildPath
@'
{
"version": "0.101.2181.0",
"channel": "private",
"intent": "private-validation",
"shouldPublishPreview": false
}
'@ | Set-Content -LiteralPath $metadataPath
Assert-Throws {
& (Join-Path $scripts "get-release-build-metadata.ps1") `
-Build 154000002 `
-BuildJsonPath $buildPath `
-MetadataJsonPath $metadataPath
}
}
}
Describe "previous published release selection" {
It "selects the latest stable or preview release before queue time" {
$releasesPath = Join-Path $TestDrive "releases.json"
@'
[
{
"tag_name": "v0.101.2171.0",
"name": "Preview",
"draft": false,
"prerelease": true,
"published_at": "2026-08-05T10:00:00Z",
"html_url": "https://example.test/preview",
"assets": []
},
{
"tag_name": "v0.100.1",
"name": "Stable",
"draft": false,
"prerelease": false,
"published_at": "2026-08-01T10:00:00Z",
"html_url": "https://example.test/stable",
"assets": []
},
{
"tag_name": "v0.101.2191.0",
"name": "Too new",
"draft": false,
"prerelease": true,
"published_at": "2026-08-07T10:00:00Z",
"html_url": "https://example.test/new",
"assets": []
}
]
'@ | Set-Content -LiteralPath $releasesPath
$result = & (Join-Path $scripts "get-previous-published-release.ps1") `
-TargetTag "v0.101.2181.0" `
-QueuedAt "2026-08-06T06:00:00Z" `
-ReleasesJsonPath $releasesPath `
-SkipSourceCommitResolution
$result.tag | Should Be "v0.101.2171.0"
$result.prerelease | Should Be $true
}
}
Describe "preview release delta" {
It "collects added PRs on the same lineage" {
$repo = Join-Path $TestDrive "same-lineage"
New-Item -ItemType Directory -Path $repo | Out-Null
Invoke-TestGit -Repository $repo init -q | Out-Null
Invoke-TestGit -Repository $repo config user.email "test@example.com" | Out-Null
Invoke-TestGit -Repository $repo config user.name "Test User" | Out-Null
$base = Add-TestCommit -Repository $repo -FileName "base.txt" -Content "base" -Message "Base"
$target = Add-TestCommit -Repository $repo -FileName "feature.txt" -Content "feature" -Message "Add feature (#101)"
$output = Join-Path $TestDrive "same-output"
$result = & (Join-Path $scripts "get-preview-release-delta.ps1") `
-PreviousCommit $base `
-TargetCommit $target `
-RepoPath $repo `
-OutputDirectory $output `
-NoGitHubLookup
$result.deltaMode | Should Be "same-lineage"
$result.addedPrNumbers.Count | Should Be 1
$result.addedPrNumbers[0] | Should Be 101
$result.removedPrNumbers.Count | Should Be 0
}
It "reports semantic additions and removals across branches" {
$repo = Join-Path $TestDrive "branch-transition"
New-Item -ItemType Directory -Path $repo | Out-Null
Invoke-TestGit -Repository $repo init -q | Out-Null
Invoke-TestGit -Repository $repo config user.email "test@example.com" | Out-Null
Invoke-TestGit -Repository $repo config user.name "Test User" | Out-Null
$root = Add-TestCommit -Repository $repo -FileName "root.txt" -Content "root" -Message "Root"
Invoke-TestGit -Repository $repo branch main $root | Out-Null
Invoke-TestGit -Repository $repo checkout -q main | Out-Null
$commonPr = Add-TestCommit -Repository $repo -FileName "common.txt" -Content "common" -Message "Common feature (#101)"
$previous = Add-TestCommit -Repository $repo -FileName "removed.txt" -Content "removed" -Message "Main-only feature (#103)"
Invoke-TestGit -Repository $repo checkout -q -b stable $root | Out-Null
Add-TestCommit -Repository $repo -FileName "stable.txt" -Content "stable" -Message "Stable fix (#102)" | Out-Null
Invoke-TestGit -Repository $repo cherry-pick --no-commit $commonPr | Out-Null
Invoke-TestGit -Repository $repo commit -q -m "Promoted common change" | Out-Null
$target = Add-TestCommit -Repository $repo -FileName "added.txt" -Content "added" -Message "Stable addition (#104)"
$output = Join-Path $TestDrive "transition-output"
$result = & (Join-Path $scripts "get-preview-release-delta.ps1") `
-PreviousCommit $previous `
-TargetCommit $target `
-RepoPath $repo `
-OutputDirectory $output `
-NoGitHubLookup
$result.deltaMode | Should Be "branch-transition"
($result.addedPrNumbers -join ",") | Should Be "102,104"
($result.removedPrNumbers -join ",") | Should Be "103"
}
}
Describe "draft preview release dry run" {
It "constructs a draft-only operation without contacting GitHub" {
$bodyPath = Join-Path $TestDrive "release-notes.md"
$assetsPath = Join-Path $TestDrive "assets"
New-Item -ItemType Directory -Path $assetsPath | Out-Null
"Preview notes" | Set-Content -LiteralPath $bodyPath
"installer" | Set-Content -LiteralPath (Join-Path $assetsPath "PowerToysSetup-0.101.2181.0-x64.exe")
"local audit only" | Set-Content -LiteralPath (Join-Path $assetsPath "release-manifest.json")
Write-TestAssetsManifest -AssetsPath $assetsPath -AssetNames @("PowerToysSetup-0.101.2181.0-x64.exe")
$result = & (Join-Path $scripts "upsert-draft-preview-release.ps1") `
-Tag "v0.101.2181.0" `
-TargetCommit "0123456789abcdef0123456789abcdef01234567" `
-BodyPath $bodyPath `
-AssetsDirectory $assetsPath `
-DryRun
$result.draft | Should Be $true
$result.prerelease | Should Be $true
$result.title | Should Be "Preview v0.101.2181.0"
$result.assetNames.Count | Should Be 1
($result.assetNames -contains "release-manifest.json") | Should Be $false
($result.assetNames -contains "assets-manifest.json") | Should Be $false
}
It "preserves human text outside managed body markers" {
$bodyPath = Join-Path $TestDrive "generated-notes.md"
$assetsPath = Join-Path $TestDrive "managed-assets"
$existingPath = Join-Path $TestDrive "existing-release.json"
$mergedPath = Join-Path $TestDrive "merged-notes.md"
New-Item -ItemType Directory -Path $assetsPath | Out-Null
@'
<!-- BEGIN POWERTOYS PREVIEW AGENT -->
New generated notes
<!-- END POWERTOYS PREVIEW AGENT -->
'@ | Set-Content -LiteralPath $bodyPath
"installer" | Set-Content -LiteralPath (Join-Path $assetsPath "PowerToysSetup-0.101.2181.0-x64.exe")
Write-TestAssetsManifest -AssetsPath $assetsPath -AssetNames @("PowerToysSetup-0.101.2181.0-x64.exe")
@'
{
"isDraft": true,
"isPrerelease": true,
"body": "Human introduction\n\n<!-- BEGIN POWERTOYS PREVIEW AGENT -->\nOld generated notes\n<!-- END POWERTOYS PREVIEW AGENT -->\n\nHuman conclusion"
}
'@ | Set-Content -LiteralPath $existingPath
& (Join-Path $scripts "upsert-draft-preview-release.ps1") `
-Tag "v0.101.2181.0" `
-TargetCommit "0123456789abcdef0123456789abcdef01234567" `
-BodyPath $bodyPath `
-AssetsDirectory $assetsPath `
-ExistingReleaseJsonPath $existingPath `
-MergedBodyOutputPath $mergedPath `
-DryRun | Out-Null
$merged = Get-Content -LiteralPath $mergedPath -Raw
$merged.Contains("Human introduction") | Should Be $true
$merged.Contains("New generated notes") | Should Be $true
$merged.Contains("Old generated notes") | Should Be $false
$merged.Contains("Human conclusion") | Should Be $true
}
It "refuses to update a published release" {
$bodyPath = Join-Path $TestDrive "published-notes.md"
$assetsPath = Join-Path $TestDrive "published-assets"
$existingPath = Join-Path $TestDrive "published-release.json"
New-Item -ItemType Directory -Path $assetsPath | Out-Null
"Preview notes" | Set-Content -LiteralPath $bodyPath
"installer" | Set-Content -LiteralPath (Join-Path $assetsPath "PowerToysSetup-0.101.2181.0-x64.exe")
Write-TestAssetsManifest -AssetsPath $assetsPath -AssetNames @("PowerToysSetup-0.101.2181.0-x64.exe")
'{"isDraft":false,"isPrerelease":true,"body":""}' | Set-Content -LiteralPath $existingPath
Assert-Throws {
& (Join-Path $scripts "upsert-draft-preview-release.ps1") `
-Tag "v0.101.2181.0" `
-TargetCommit "0123456789abcdef0123456789abcdef01234567" `
-BodyPath $bodyPath `
-AssetsDirectory $assetsPath `
-ExistingReleaseJsonPath $existingPath `
-DryRun
}
}
It "rejects undeclared executable and ZIP assets" {
$bodyPath = Join-Path $TestDrive "extra-notes.md"
$assetsPath = Join-Path $TestDrive "extra-assets"
New-Item -ItemType Directory -Path $assetsPath | Out-Null
"Preview notes" | Set-Content -LiteralPath $bodyPath
"installer" | Set-Content -LiteralPath (Join-Path $assetsPath "PowerToysSetup-0.101.2181.0-x64.exe")
"unexpected" | Set-Content -LiteralPath (Join-Path $assetsPath "unexpected.zip")
Write-TestAssetsManifest -AssetsPath $assetsPath -AssetNames @("PowerToysSetup-0.101.2181.0-x64.exe")
Assert-Throws {
& (Join-Path $scripts "upsert-draft-preview-release.ps1") `
-Tag "v0.101.2181.0" `
-TargetCommit "0123456789abcdef0123456789abcdef01234567" `
-BodyPath $bodyPath `
-AssetsDirectory $assetsPath `
-DryRun
}
}
It "rejects an asset whose contents no longer match the manifest" {
$bodyPath = Join-Path $TestDrive "tampered-notes.md"
$assetsPath = Join-Path $TestDrive "tampered-assets"
New-Item -ItemType Directory -Path $assetsPath | Out-Null
"Preview notes" | Set-Content -LiteralPath $bodyPath
$installerPath = Join-Path $assetsPath "PowerToysSetup-0.101.2181.0-x64.exe"
"installer" | Set-Content -LiteralPath $installerPath -NoNewline
Write-TestAssetsManifest -AssetsPath $assetsPath -AssetNames @("PowerToysSetup-0.101.2181.0-x64.exe")
"tampered!" | Set-Content -LiteralPath $installerPath -NoNewline
Assert-Throws {
& (Join-Path $scripts "upsert-draft-preview-release.ps1") `
-Tag "v0.101.2181.0" `
-TargetCommit "0123456789abcdef0123456789abcdef01234567" `
-BodyPath $bodyPath `
-AssetsDirectory $assetsPath `
-DryRun
}
}
It "writes a complete local final review in dry-run mode" {
$bodyPath = Join-Path $TestDrive "dry-run-notes.md"
$assetsPath = Join-Path $TestDrive "dry-run-assets"
$deltaPath = Join-Path $TestDrive "dry-run-delta"
$contextPath = Join-Path $TestDrive "release-context.json"
$previousReleasePath = Join-Path $TestDrive "previous-release.json"
$reviewPath = Join-Path $TestDrive "final-review.md"
New-Item -ItemType Directory -Path $assetsPath | Out-Null
New-Item -ItemType Directory -Path $deltaPath | Out-Null
@'
<!-- BEGIN POWERTOYS PREVIEW AGENT -->
Preview notes
<!-- END POWERTOYS PREVIEW AGENT -->
'@ | Set-Content -LiteralPath $bodyPath
"installer" | Set-Content -LiteralPath (Join-Path $assetsPath "PowerToysSetup-0.101.2181.0-x64.exe")
Write-TestAssetsManifest -AssetsPath $assetsPath -AssetNames @("PowerToysSetup-0.101.2181.0-x64.exe")
"[]" | Set-Content -LiteralPath (Join-Path $deltaPath "delta-prs.json")
"[]" | Set-Content -LiteralPath (Join-Path $deltaPath "removed-prs.json")
'[{"sha":"abcdef0123456789abcdef0123456789abcdef01","subject":"Aggregate promotion commit"}]' |
Set-Content -LiteralPath (Join-Path $deltaPath "unattributed-commits.json")
'{"deltaMode":"same-lineage","mergeBase":null}' |
Set-Content -LiteralPath (Join-Path $deltaPath "delta-commits.json")
@'
{
"buildId": 154000000,
"buildUrl": "https://microsoft.visualstudio.com/Dart/_build/results?buildId=154000000",
"version": "0.101.2181.0",
"sourceBranch": "refs/heads/main",
"sourceCommit": "0123456789abcdef0123456789abcdef01234567",
"intent": "preview-release",
"channel": "preview"
}
'@ | Set-Content -LiteralPath $contextPath
@'
{
"tag": "v0.100.0",
"sourceCommit": "1123456789abcdef0123456789abcdef01234567"
}
'@ | Set-Content -LiteralPath $previousReleasePath
$result = & (Join-Path $scripts "verify-draft-preview-release.ps1") `
-Tag "v0.101.2181.0" `
-TargetCommit "0123456789abcdef0123456789abcdef01234567" `
-AssetsDirectory $assetsPath `
-BodyPath $bodyPath `
-ContextPath $contextPath `
-PreviousReleasePath $previousReleasePath `
-DeltaDirectory $deltaPath `
-OutputPath $reviewPath `
-DryRun
$result.status | Should Be "PASS"
$result.draftUrl | Should Be $null
$review = Get-Content -LiteralPath $reviewPath -Raw
$review.Contains("Local dry-run package is complete") | Should Be $true
$review.Contains("abcdef0123456789abcdef0123456789abcdef01") | Should Be $true
$review.Contains("Aggregate promotion commit") | Should Be $true
$review.Contains("154000000") | Should Be $true
$review.Contains("v0.100.0@1123456789ab") | Should Be $true
$review.Contains("Delta mode: same-lineage") | Should Be $true
}
}
Describe "preview PR metadata attribution" {
It "rejects a missing member list before fetching PRs" {
Assert-Throws {
& (Join-Path $scripts "collect-pr-metadata.ps1") `
-PrNumbers @(123) `
-OutputDirectory (Join-Path $TestDrive "missing-members") `
-MemberListPath (Join-Path $TestDrive "MemberList.md")
}
}
It "rejects an empty member list before fetching PRs" {
$memberListPath = Join-Path $TestDrive "EmptyMemberList.md"
"" | Set-Content -LiteralPath $memberListPath
Assert-Throws {
& (Join-Path $scripts "collect-pr-metadata.ps1") `
-PrNumbers @(123) `
-OutputDirectory (Join-Path $TestDrive "empty-members") `
-MemberListPath $memberListPath
}
}
}

View File

@@ -3,6 +3,9 @@ param(
[AllowEmptyString()]
[string]$VersionOverride = "",
[ValidateSet("auto", "preview-release", "stable-release")]
[string]$ReleaseIntent = "auto",
[string]$SourceBranch = $env:BUILD_SOURCEBRANCH,
[string]$BuildReason = $env:BUILD_REASON,
@@ -295,8 +298,17 @@ $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"
if ($ReleaseIntent -eq "stable-release" -and -not $isStable) {
throw "Stable release intent is only supported from refs/heads/stable"
}
if ($ReleaseIntent -eq "preview-release" -and -not ($isMain -or $isStable)) {
throw "Preview release intent is only supported from refs/heads/main or refs/heads/stable"
}
if ($isScheduled -and $isStable -and $ReleaseIntent -ne "preview-release") {
throw "Scheduled stable builds must explicitly use preview-release intent"
}
if ($isScheduled -and -not ($isMain -or $isStable)) {
throw "Scheduled release builds are only supported from refs/heads/main or refs/heads/stable"
}
$releaseMetadata = Get-ReleaseTrainMetadata -Path $VersionPropsPath
@@ -308,7 +320,7 @@ if ($isMain) {
throw "Scheduled main builds must use the checked-in ReleaseTrainVersion and cannot specify a version override"
}
$intent = if ($isScheduled) { "preview-release" } else { "preview-validation" }
$intent = if ($isScheduled -or $ReleaseIntent -eq "preview-release") { "preview-release" } else { "preview-validation" }
$channel = "preview"
$version = Get-PreviewVersionOverride -ReleaseTrain $releaseTrain -Override $VersionOverride
if ($null -eq $version) {
@@ -320,16 +332,24 @@ if ($isMain) {
-DailySequence $DailyVersionSequence
}
$allowPublicSymbols = $false
$shouldPublishPreview = $isScheduled
$shouldPublishPreview = $intent -eq "preview-release"
}
elseif ($isStable) {
if ($isScheduled) {
throw "Stable release builds must be queued manually"
if ($ReleaseIntent -eq "preview-release") {
$intent = "preview-release"
$channel = "preview"
$version = Get-PreviewVersionOverride -ReleaseTrain $releaseTrain -Override $VersionOverride
$allowPublicSymbols = $false
$shouldPublishPreview = $true
}
else {
$intent = "stable-release"
$channel = "stable"
$version = Get-StableVersionOverride -Override $VersionOverride
$allowPublicSymbols = $true
$shouldPublishPreview = $false
}
$intent = "stable-release"
$channel = "stable"
$version = Get-StableVersionOverride -Override $VersionOverride
if ($null -eq $version) {
$version = Get-AutomaticReleaseVersion `
-ReleaseTrain $releaseTrain `
@@ -338,8 +358,6 @@ elseif ($isStable) {
-DateOverride $BuildDate `
-DailySequence $DailyVersionSequence
}
$allowPublicSymbols = $true
$shouldPublishPreview = $false
}
else {
$intent = "private-validation"

View File

@@ -75,6 +75,45 @@ Describe "resolveBuildMetadata" {
$result.Version | Should Be "0.100.2112.0"
}
It "supports an explicit preview release from stable" {
$result = & $scriptPath `
-ReleaseIntent "preview-release" `
-SourceBranch "refs/heads/stable" `
-BuildReason "Schedule" `
-BuildNumber "PowerToys Signed YAML Release Build_2607.30099-stable" `
-DailyVersionSequence "2" `
-VersionPropsPath (New-VersionProps)
$result.Intent | Should Be "preview-release"
$result.Channel | Should Be "preview"
$result.Version | Should Be "0.100.2112.0"
$result.AllowPublicSymbols | Should Be $false
$result.ShouldPublishPreview | Should Be $true
}
It "rejects a scheduled stable build without explicit preview intent" {
Assert-Throws {
& $scriptPath `
-SourceBranch "refs/heads/stable" `
-BuildReason "Schedule" `
-BuildNumber "PowerToys Signed YAML Release Build_2607.30099-stable" `
-DailyVersionSequence "2" `
-VersionPropsPath (New-VersionProps)
}
}
It "rejects stable release intent from main" {
Assert-Throws {
& $scriptPath `
-ReleaseIntent "stable-release" `
-SourceBranch "refs/heads/main" `
-BuildReason "Manual" `
-BuildNumber "PowerToys Signed YAML Release Build_2607.30099-main" `
-DailyVersionSequence "2" `
-VersionPropsPath (New-VersionProps)
}
}
It "keeps private builds independent from the release counter" {
$result = & $scriptPath `
-SourceBranch "refs/heads/user/feature" `

View File

@@ -0,0 +1,96 @@
# Copyright (c) Microsoft Corporation
# The Microsoft Corporation licenses this file to you under the MIT license.
$scriptPath = Join-Path $PSScriptRoot "..\writeReleaseMetadata.ps1"
function Assert-Throws {
param([scriptblock]$Action)
$threw = $false
try {
& $Action | Out-Null
}
catch {
$threw = $true
}
$threw | Should Be $true
}
Describe "writeReleaseMetadata" {
It "writes the authoritative preview candidate contract" {
$path = Join-Path $TestDrive "release-metadata.json"
$result = & $scriptPath `
-DefinitionId 76541 `
-BuildId 154000000 `
-BuildNumber "PowerToys Signed YAML Release Build_2608.06001-main" `
-Version "0.101.2181.0" `
-Channel "preview" `
-Intent "preview-release" `
-SourceBranch "refs/heads/main" `
-SourceCommit "0123456789abcdef0123456789abcdef01234567" `
-BuildReason "Schedule" `
-ShouldPublishPreview "True" `
-QueuedAt "2026-08-06T06:00:00Z" `
-StartedAt "2026-08-06T06:00:20Z" `
-OutputPath $path
$result.shouldPublishPreview | Should Be $true
$stored = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
$stored.definitionId | Should Be 76541
$stored.version | Should Be "0.101.2181.0"
$stored.sourceCommit | Should Be "0123456789abcdef0123456789abcdef01234567"
}
It "rejects inconsistent preview intent and channel" {
Assert-Throws {
& $scriptPath `
-DefinitionId 76541 `
-BuildId 154000000 `
-BuildNumber "build" `
-Version "0.101.2181.0" `
-Channel "stable" `
-Intent "preview-release" `
-SourceBranch "refs/heads/stable" `
-SourceCommit "0123456789abcdef0123456789abcdef01234567" `
-BuildReason "Manual" `
-ShouldPublishPreview "True" `
-OutputPath (Join-Path $TestDrive "invalid.json")
}
}
It "allows a non-publishing preview validation build" {
$result = & $scriptPath `
-DefinitionId 76541 `
-BuildId 154000001 `
-BuildNumber "manual-main" `
-Version "0.101.2182.0" `
-Channel "preview" `
-Intent "preview-validation" `
-SourceBranch "refs/heads/main" `
-SourceCommit "1123456789abcdef0123456789abcdef01234567" `
-BuildReason "Manual" `
-ShouldPublishPreview "False" `
-OutputPath (Join-Path $TestDrive "preview-validation.json")
$result.intent | Should Be "preview-validation"
$result.shouldPublishPreview | Should Be $false
}
It "records private validation metadata from a feature branch" {
$result = & $scriptPath `
-DefinitionId 76541 `
-BuildId 154000002 `
-BuildNumber "feature-build" `
-Version "0.0.21801.0" `
-Channel "private" `
-Intent "private-validation" `
-SourceBranch "refs/heads/user/feature" `
-SourceCommit "2123456789abcdef0123456789abcdef01234567" `
-BuildReason "Manual" `
-ShouldPublishPreview "False" `
-OutputPath (Join-Path $TestDrive "private-validation.json")
$result.sourceBranch | Should Be "refs/heads/user/feature"
$result.channel | Should Be "private"
}
}

View File

@@ -20,6 +20,15 @@ parameters:
type: string
default: 'auto'
- name: releaseIntent
displayName: "Release Intent"
type: string
default: auto
values:
- auto
- preview-release
- stable-release
- name: buildConfigurations
displayName: "Build Configurations"
type: object
@@ -120,6 +129,7 @@ extends:
- pwsh: |-
$metadata = .pipelines/resolveBuildMetadata.ps1 `
-VersionOverride '${{ parameters.versionNumber }}' `
-ReleaseIntent '${{ parameters.releaseIntent }}' `
-BuildDate '$(versionDate)' `
-DailyVersionSequence '$(dailyVersionSequence)'
$publishSymbolsToPublic = $${{ parameters.publishSymbolsToPublic }}
@@ -138,6 +148,25 @@ extends:
Write-Host "##vso[task.setvariable variable=ShouldPublishPreview]$($metadata.ShouldPublishPreview)"
displayName: Prepare versioning
- pwsh: |-
$path = Join-Path '$(Build.ArtifactStagingDirectory)' 'release-metadata.json'
.pipelines/writeReleaseMetadata.ps1 `
-DefinitionId '$(System.DefinitionId)' `
-BuildId '$(Build.BuildId)' `
-BuildNumber '$(Build.BuildNumber)' `
-Version '$(ResolvedVersionNumber)' `
-Channel '$(ResolvedReleaseChannel)' `
-Intent '$(ResolvedBuildIntent)' `
-SourceBranch '$(Build.SourceBranch)' `
-SourceCommit '$(Build.SourceVersion)' `
-BuildReason '$(Build.Reason)' `
-ShouldPublishPreview '$(ShouldPublishPreview)' `
-QueuedAt '$(Build.QueuedTime)' `
-StartedAt '$(System.PipelineStartTime)' `
-OutputPath $path | Out-Null
Write-Host "Staged release metadata: $path"
displayName: Stage release metadata
# Prepare the localizations and telemetry config before the release build
- template: .pipelines/v2/templates/steps-fetch-and-prepare-localizations.yml@self
@@ -181,6 +210,7 @@ extends:
- template: .pipelines/v2/templates/job-publish-symbols-using-symbolrequestprod-api.yml@self
parameters:
versionNumber: ${{ parameters.versionNumber }}
releaseIntent: ${{ parameters.releaseIntent }}
includePublicSymbolServer: ${{ parameters.publishSymbolsToPublic }}
${{ if ne(parameters.publishSymbolsToPublic, true) }}:
symbolExpiryTime: 10 # For private builds, expire symbols within 10 days. The default is 100 years.

View File

@@ -11,6 +11,9 @@ parameters:
- name: versionNumber
type: string
default: '0.0.1'
- name: releaseIntent
type: string
default: auto
- name: resolvedVersionNumber
type: string
default: ''
@@ -60,6 +63,7 @@ jobs:
if ([string]::IsNullOrWhiteSpace($effectiveVersionNumber) -or $effectiveVersionNumber.StartsWith('$(')) {
$metadata = .pipelines/resolveBuildMetadata.ps1 `
-VersionOverride '${{ parameters.versionNumber }}' `
-ReleaseIntent '${{ parameters.releaseIntent }}' `
-BuildDate '$(versionDate)' `
-DailyVersionSequence '$(dailyVersionSequence)'
if ($${{ parameters.includePublicSymbolServer }} -and -not $metadata.AllowPublicSymbols) {

View File

@@ -0,0 +1,85 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)][int]$DefinitionId,
[Parameter(Mandatory)][int]$BuildId,
[Parameter(Mandatory)][string]$BuildNumber,
[Parameter(Mandatory)][string]$Version,
[Parameter(Mandatory)][ValidateSet("private", "preview", "stable")][string]$Channel,
[Parameter(Mandatory)][ValidateSet("private-validation", "preview-validation", "preview-release", "stable-release")][string]$Intent,
[Parameter(Mandatory)][string]$SourceBranch,
[Parameter(Mandatory)][string]$SourceCommit,
[Parameter(Mandatory)][string]$BuildReason,
[Parameter(Mandatory)][string]$ShouldPublishPreview,
[AllowEmptyString()][string]$QueuedAt = "",
[AllowEmptyString()][string]$StartedAt = "",
[Parameter(Mandatory)][string]$OutputPath
)
$ErrorActionPreference = "Stop"
if ($Version -notmatch "^\d+\.\d+\.\d+\.0$") {
throw "Version '$Version' must use the four-component PowerToys release format."
}
if ($SourceCommit -notmatch "^[0-9a-fA-F]{40}$") {
throw "SourceCommit must be a full immutable commit SHA."
}
if ($SourceBranch -notmatch "^refs/heads/.+") {
throw "SourceBranch '$SourceBranch' is not a branch ref."
}
if ($Intent -ne "private-validation" -and $SourceBranch -notin @("refs/heads/main", "refs/heads/stable")) {
throw "Intent '$Intent' is only supported from main or stable."
}
$publishPreview = switch ($ShouldPublishPreview.ToLowerInvariant()) {
"true" { $true }
"false" { $false }
default { throw "ShouldPublishPreview must be true or false." }
}
if ($publishPreview -ne ($Intent -eq "preview-release")) {
throw "ShouldPublishPreview '$publishPreview' conflicts with intent '$Intent'."
}
$expectedChannel = switch ($Intent) {
"private-validation" { "private" }
"preview-validation" { "preview" }
"preview-release" { "preview" }
"stable-release" { "stable" }
}
if ($Channel -ne $expectedChannel) {
throw "Intent '$Intent' requires channel '$expectedChannel', not '$Channel'."
}
function ConvertTo-NullablePipelineValue {
param([AllowEmptyString()][string]$Value)
if ([string]::IsNullOrWhiteSpace($Value) -or $Value.StartsWith('$(')) {
return $null
}
return $Value
}
$metadata = [ordered]@{
schemaVersion = 1
definitionId = $DefinitionId
buildId = $BuildId
buildNumber = $BuildNumber
result = "succeeded"
version = $Version
channel = $Channel
intent = $Intent
sourceBranch = $SourceBranch
sourceCommit = $SourceCommit.ToLowerInvariant()
buildReason = $BuildReason
queuedAt = ConvertTo-NullablePipelineValue -Value $QueuedAt
startedAt = ConvertTo-NullablePipelineValue -Value $StartedAt
finishedAt = $null
shouldPublishPreview = $publishPreview
}
$parent = Split-Path -Parent $OutputPath
if ($parent) {
New-Item -ItemType Directory -Path $parent -Force | Out-Null
}
$metadata | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $OutputPath -Encoding utf8
[pscustomobject]$metadata