
Release Note Generation
- 112 installs
- 137k repo stars
- Updated July 28, 2026
- microsoft/powertoys
PowerToys skill generating release notes from GitHub milestones or commit ranges.
About
PowerToys release note generation toolkit. Pulls merged PRs from GitHub milestones or specified commit ranges, categorizes changes by area, and drafts release notes suitable for GitHub releases and user-facing changelogs. Supports summarizing milestone PRs, generating changelog entries, and preparing release documentation for PowerToys maintainers. Automates the tedious aggregation of contributor PRs into readable release summaries with consistent formatting.
- Release notes from GitHub milestone PRs or commit ranges
- Categorizes changes by PowerToys feature area
- Drafts GitHub release and changelog content
- Automates PR aggregation for release preparation
- Toolkit for maintainers preparing PowerToys releases
Release Note Generation by the numbers
- 112 all-time installs (skills.sh)
- Ranked #107 of 257 Release Management skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
release-note-generation capabilities & compatibility
- Capabilities
- aggregate milestone prs · draft release notes · categorize changes
- Works with
- github
- Use cases
- documentation
What release-note-generation says it does
Toolkit for generating PowerToys release notes from GitHub milestone PRs or commit ranges.
npx skills add https://github.com/microsoft/powertoys --skill release-note-generationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 112 |
|---|---|
| repo stars | ★ 137k |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 28, 2026 |
| Repository | microsoft/powertoys ↗ |
How do I generate PowerToys release notes for a milestone?
Generate PowerToys release notes from GitHub milestone PRs or commit ranges for changelog and release preparation.
Who is it for?
PowerToys maintainers preparing releases, changelogs, or milestone summaries.
Skip if: Non-PowerToys projects or runtime code changes.
When should I use this skill?
User asks to create release notes, summarize milestone PRs, or generate changelog.
What you get
Drafted release notes categorizing merged PRs for GitHub release publication.
Files
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.
Output Directory
All generated artifacts are placed under Generated Files/ReleaseNotes/ at the repository root (gitignored).
Generated Files/ReleaseNotes/
├── milestone_prs.json # Raw PR data from GitHub
├── sorted_prs.csv # Sorted PR list with Copilot summaries
├── prs_with_milestone.csv # Milestone assignment tracking
├── grouped_csv/ # PRs grouped by label (one CSV per label)
├── grouped_md/ # Generated markdown summaries per label
└── v{VERSION}-release-notes.md # Final consolidated release notesWhen to Use This Skill
- Generate release notes for a milestone
- Summarize PRs merged in a release
- Generate per-PR review summaries locally for release-notes copy
- Assign milestones to PRs missing them
- Collect PRs between two commits/tags
- Update README.md for a new version
- Prepare GitHub release assets (download installers/symbols + compute hashes)
Prerequisites
- GitHub CLI (`gh`) installed and authenticated — The collection script uses
gh pr viewandgh api graphqlto fetch PR metadata and co-author information. Rungh auth statusto verify; if not logged in, rungh auth loginfirst. See Step 1.0.0 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 only: Azure CLI authenticated against the Microsoft tenant (
az login) with theazure-devopsextension; access to themicrosoft/DartADO 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)"
| Variable | Description | Example |
|---|---|---|
{{ReleaseVersion}} | Target release version | 0.98 |
Workflow Overview
┌────────────────────────────────┐
│ 1.0 Verify gh auth + MemberList │
└────────────────────────────────┘
↓
┌────────────────────────────────┐
│ 1.1 Collect PRs (stable range) │
└────────────────────────────────┘
↓
┌────────────────────────────────┐
│ 1.2 Assign Milestones │
└────────────────────────────────┘
↓
┌────────────────────────────────┐
│ 2.1–2.4 Label PRs (auto+human) │
└────────────────────────────────┘
↓
┌────────────────────────────────┐
│ 3.1 Local-agent PR summaries │
│ (writes CopilotSummary) │
└────────────────────────────────┘
↓
┌────────────────────────────────┐
│ 3.2 (Optional) Refresh PR data │
└────────────────────────────────┘
↓
┌────────────────────────────────┐
│ 3.3 Group by label │
│ (grouped_csv) │
└────────────────────────────────┘
↓
┌────────────────────────────────┐
│ 4.1 Summarize (grouped_md) │
└────────────────────────────────┘
↓
┌────────────────────────────────┐
│ 4.2 Final notes (v{VERSION}.md) │
└────────────────────────────────┘| Step | Action | Details |
|---|---|---|
| 1.0 | Verify prerequisites | gh auth status must pass; generate MemberList.md |
| 1.1 | Collect PRs | From previous release tag on stable branch → sorted_prs.csv |
| 1.2 | Assign Milestones | Ensure all PRs have correct milestone |
| 2.1–2.4 | Label PRs | Auto-suggest + human label low-confidence |
| 3.1–3.3 | Reviews & Grouping | Local agent summarizes each PR diff into CopilotSummary → (optional refresh) → group by label |
| 4.1–4.2 | Summaries & Final | Generate grouped summaries, then consolidate |
Detailed workflow docs
Do not read all steps at once—only read the step you are executing.
- Step 1: Collection & Milestones
- Step 2: Labeling PRs
- Step 3: Reviews & Grouping
- Step 4: Summarization
Available Scripts
| Script | Purpose |
|---|---|
| dump-prs-since-commit.ps1 | Fetch PRs between commits/tags |
| group-prs-by-label.ps1 | Group PRs into CSVs |
| collect-or-apply-milestones.ps1 | Assign milestones |
| diff_prs.ps1 | Incremental PR diff |
| prepare-release-assets.ps1 | Download installers + symbols from an ADO build, compute SHA256, emit the "Installer Hashes" markdown table for the GitHub release page |
References
- Sample Output - Example summary formatting
- Detailed Instructions - Legacy full documentation
Conventions
- Terminal usage: Disabled by default; only run scripts when user explicitly requests
- Batch generation: Generate ALL grouped_md files in one pass, then human reviews
- PR order: Preserve order from
sorted_prs.csvin all outputs - Label filtering: Keeps
Product-*,Area-*,GitHub*,*Plugin,Issue-*
Troubleshooting
| Issue | Solution |
|---|---|
gh command not found | Install GitHub CLI and add to PATH |
| No PRs returned | Verify milestone title matches exactly |
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 |
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Microsoft Corporation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
- Added mouse button actions so you can choose what left, right, or middle click does in #1234 by @PesBandi
- Ensured screen readers are notified when the selected item in the list changes for better accessibility in #1236
- Implemented configurable UI test pipeline that can use pre-built official releases instead of building everything from scratch, reducing test execution time from 2+ hours in #1237
- Fixed Alt+Left Arrow navigation not working when search box contains text in #1238 by @jiripolasek
Step 1: Collection and Milestones
1.0 To-do
- 1.0.0 Verify GitHub CLI authentication (REQUIRED)
- 1.0.1 Generate MemberList.md (REQUIRED)
- 1.1 Collect PRs
- 1.2 Assign Milestones (REQUIRED)
Required Variables
⚠️ Before starting, confirm these values with the user:
| Variable | Description | Example |
|---|---|---|
{{ReleaseVersion}} | Target release version | 0.97 |
{{PreviousReleaseTag}} | Previous release tag from releases page | v0.96.1 |
If user hasn't specified `{{ReleaseVersion}}`, ASK: "What release version are we generating notes for? (e.g., 0.97)"
`{{PreviousReleaseTag}}` is derived from the releases page, not user input. Use the latest published release tag (top of the page). You will use its tag name and tag commit SHA in Step 1.
---
1.0.0 Verify GitHub CLI Authentication (REQUIRED)
⚠️ BLOCKING: The collection script requires an authenticated gh CLI to fetch PR metadata and co-author information via GitHub's GraphQL API. Without authentication, PR data and NeedThanks attribution will be incomplete.
Check authentication status
gh auth statusIf authenticated: You'll see Logged in to github.com account <username>. Proceed to 1.0.1.
If NOT authenticated: Run the login flow before continuing:
# Interactive login (opens browser for OAuth)
gh auth login --hostname github.com --web
# Or use a personal access token
gh auth login --with-token <<< "YOUR_GITHUB_TOKEN"Required scopes: repo (for reading PR data and assigning milestones)
After login, verify again with gh auth status and confirm exit code 0.
---
1.0.1 Generate MemberList.md (REQUIRED)
Create Generated Files/ReleaseNotes/MemberList.md from the PowerToys core team section in COMMUNITY.md.
Rules:
- One GitHub username per line, no
@prefix. - Use the usernames exactly as listed in the core team section.
- Do not include former team members or other sections.
Example (format only):
example-user
another-user---
1.1 Collect PRs
1.1.1 Get the previous release commit
1. Open the PowerToys releases page 2. Find the latest release (e.g., v0.96.1, which should be at the top) 3. Set {{PreviousReleaseTag}} to that tag name (e.g., v0.96.1) 4. Copy the full tag commit SHA as {{SHALastRelease}}
If the release SHA is not in your branch history: Use the helper script to find an equivalent commit on the target branch by matching the commit title:
pwsh ./.github/skills/release-note-generation/scripts/find-commit-by-title.ps1 `
-Commit '{{SHALastRelease}}' `
-Branch 'stable'1.1.2 Run collection script against stable branch
# Collect PRs from previous release to current HEAD of stable branch
pwsh ./.github/skills/release-note-generation/scripts/dump-prs-since-commit.ps1 `
-StartCommit '{{SHALastRelease}}' `
-Branch 'stable' `
-OutputDir 'Generated Files/ReleaseNotes'Parameters:
-StartCommit- Previous release tag or commit SHA (exclusive)-Branch- Always usestablebranch, notmain(script usesorigin/stableas the end ref)-EndCommit- Optional override if you need a custom end ref-OutputDir- Output directory for generated files
Reliability check: If the script reports “No commits found”, the stable branch has not moved since the last release. In that case, either:
- Confirm this is expected and stop (no new release notes), or
- Re-run against
mainto gather pending changes for the next release cycle.
The script detects both merge commits (Merge pull request #12345) and squash commits (Feature (#12345)).
Output (in Generated Files/ReleaseNotes/):
milestone_prs.json- raw PR datasorted_prs.csv- sorted PR list with columns: Id, Title, Labels, Author, Url, Body, CopilotSummary, NeedThanksAuthor: Comma-separated list of all contributors (PR opener + co-authors from commit trailers)NeedThanks: Comma-separated list of external contributors to thank (excludes core team members from MemberList.md). Empty string means no thanks needed.
---
1.2 Assign Milestones (REQUIRED)
Before generating release notes, ensure all collected PRs have the correct milestone assigned.
⚠️ CRITICAL: Do NOT proceed to labeling until all PRs have milestones assigned.
1.2.1 Check current milestone status (dry run)
# Dry run first to see what would be changed:
pwsh ./.github/skills/release-note-generation/scripts/collect-or-apply-milestones.ps1 `
-InputCsv 'Generated Files/ReleaseNotes/sorted_prs.csv' `
-OutputCsv 'Generated Files/ReleaseNotes/prs_with_milestone.csv' `
-DefaultMilestone 'PowerToys {{ReleaseVersion}}' `
-ApplyMissing -WhatIfThis queries GitHub for each PR's current milestone and shows which PRs would be updated.
1.2.2 Apply milestones to PRs missing them
# Apply for real:
pwsh ./.github/skills/release-note-generation/scripts/collect-or-apply-milestones.ps1 `
-InputCsv 'Generated Files/ReleaseNotes/sorted_prs.csv' `
-OutputCsv 'Generated Files/ReleaseNotes/prs_with_milestone.csv' `
-DefaultMilestone 'PowerToys {{ReleaseVersion}}' `
-ApplyMissingScript Behavior:
- Queries each PR's current milestone from GitHub
- PRs that already have a milestone are skipped (not overwritten)
- PRs missing a milestone get the default milestone applied
- Outputs
prs_with_milestone.csvwith (Id, Milestone) columns - Produces summary:
Updated=X Skipped=Y Failed=Z
Validation: After assignment, all PRs in prs_with_milestone.csv should have the target milestone.
---
Additional Commands
Collect milestones only (no changes to GitHub)
pwsh ./.github/skills/release-note-generation/scripts/collect-or-apply-milestones.ps1 `
-InputCsv 'Generated Files/ReleaseNotes/sorted_prs.csv' `
-OutputCsv 'Generated Files/ReleaseNotes/prs_with_milestone.csv'Local assignment only (fill blanks in CSV, no GitHub changes)
pwsh ./.github/skills/release-note-generation/scripts/collect-or-apply-milestones.ps1 `
-InputCsv 'Generated Files/ReleaseNotes/sorted_prs.csv' `
-OutputCsv 'Generated Files/ReleaseNotes/prs_with_milestone.csv' `
-DefaultMilestone 'PowerToys {{ReleaseVersion}}' `
-LocalAssignStep 2: Label Unlabeled PRs
2.0 To-do
- 2.1 Identify unlabeled PRs (Agent Mode)
- 2.2 Suggest labels (Agent Mode)
- 2.3 Human label low-confidence PRs
- 2.4 Recheck labels, delete Unlabeled.csv, and re-collect
Before grouping, ensure all PRs have appropriate labels for categorization.
⚠️ CRITICAL: Do NOT proceed to grouping until all PRs have labels assigned. PRs without labels will end up in Unlabeled.csv and won't appear in the correct release note sections.
2.1 Identify unlabeled PRs (Agent Mode)
Read sorted_prs.csv and identify PRs with empty or missing Labels column.
For each unlabeled PR, analyze:
- Title - Often contains module name or feature
- Body - PR description with context
- CopilotSummary - AI-generated summary of changes
2.2 Suggest labels (Agent Mode)
For each unlabeled PR, suggest an appropriate label based on the content analysis.
Output: Create Generated Files/ReleaseNotes/prs_label_review.md with the following format:
# PR Label Review
Generated: YYYY-MM-DD HH:mm:ss
## Summary
- Total unlabeled PRs: X
- High confidence: X
- Medium confidence: X
- Low confidence: X
---
## PRs Needing Review (sorted by confidence, low first)
| PR | Title | Suggested Label | Confidence | Reason |
|----|-------|-----------------|------------|--------|
| [#12347](url) | Some generic fix | ??? | Low | Unclear from content |
| [#12346](url) | Update dependencies | `Area-Build` | Medium | Body mentions NuGet packages |Sort by confidence (low first) so human reviews uncertain ones first.
After writing prs_label_review.md, generate `prs_to_label.csv`, apply labels, and re-run collection so the CSV/labels stay in sync:
# Generate CSV from suggestions (agent)
# Apply labels
pwsh ./.github/skills/release-note-generation/scripts/apply-labels.ps1 `
-InputCsv 'Generated Files/ReleaseNotes/prs_to_label.csv'
# Refresh collection
pwsh ./.github/skills/release-note-generation/scripts/dump-prs-since-commit.ps1 `
-StartCommit '{{PreviousReleaseTag}}' -Branch 'stable' `
-OutputDir 'Generated Files/ReleaseNotes'2.3 Human label low-confidence PRs
Ask the human to label low-confidence PRs directly (in GitHub). Skip any they decide not to label.
2.4 Recheck labels, delete Unlabeled.csv, and re-collect
Recheck that all PRs now have labels. Delete Unlabeled.csv (if present), then re-run the collection script to update sorted_prs.csv:
# Remove stale unlabeled output if it exists
Remove-Item 'Generated Files/ReleaseNotes/Unlabeled.csv' -ErrorAction SilentlyContinuepwsh ./.github/skills/release-note-generation/scripts/dump-prs-since-commit.ps1 `
-StartCommit '{{PreviousReleaseTag}}' -Branch 'stable' `
-OutputDir 'Generated Files/ReleaseNotes'---
Common Label Mappings
| Keywords/Patterns | Suggested Label |
|---|---|
| Advanced Paste, AP, clipboard, paste | Product-Advanced Paste |
| CmdPal, Command Palette, cmdpal | Product-Command Palette |
| FancyZones, zones, layout | Product-FancyZones |
| ZoomIt, zoom, screen annotation | Product-ZoomIt |
| Settings, settings-ui, Quick Access, flyout | Product-Settings |
| Installer, setup, MSI, MSIX, WiX | Area-Setup/Install |
| Build, pipeline, CI/CD, msbuild | Area-Build |
| Test, unit test, UI test, fuzz | Area-Tests |
| Localization, loc, translation, resw | Area-Localization |
| Foundry, AI, LLM | Product-Advanced Paste (AI features) |
| Mouse Without Borders, MWB | Product-Mouse Without Borders |
| PowerRename, rename, regex | Product-PowerRename |
| Peek, preview, file preview | Product-Peek |
| Image Resizer, resize | Product-Image Resizer |
| LightSwitch, theme, dark mode | Product-LightSwitch |
| Quick Accent, accent, diacritics | Product-Quick Accent |
| Awake, keep awake, caffeine | Product-Awake |
| ColorPicker, color picker, eyedropper | Product-ColorPicker |
| Hosts, hosts file | Product-Hosts |
| Keyboard Manager, remap | Product-Keyboard Manager |
| Mouse Highlighter | Product-Mouse Highlighter |
| Mouse Jump | Product-Mouse Jump |
| Find My Mouse | Product-Find My Mouse |
| Mouse Pointer Crosshairs | Product-Mouse Pointer Crosshairs |
| Shortcut Guide | Product-Shortcut Guide |
| Text Extractor, OCR, PowerOCR | Product-Text Extractor |
| Workspaces | Product-Workspaces |
| File Locksmith | Product-File Locksmith |
| Crop And Lock | Product-CropAndLock |
| Environment Variables | Product-Environment Variables |
| New+ | Product-New+ |
Label Filtering Rules
The grouping script keeps labels matching these patterns:
Product-*Area-*GitHub**PluginIssue-*
Other labels are ignored for grouping purposes.
Step 3: Local Agent Reviews and Grouping
3.0 To-do
- 3.1 Generate PR Summaries with the Local Agent
- 3.2 (Optional) Refresh PR Data
- 3.3 Group PRs by Label
3.1 Generate PR Summaries with the Local Agent
⚠️ Do not use `mcp_github_request_copilot_review` (or any "request Copilot review" tool that calls the GitHub API).
When this skill is driven from a CLI / coding agent, the request is made from a bot identity and the GitHub API rejects it ("Bot reviewers cannot be requested"). The PR ends up with no Copilot review and CopilotSummary stays empty.>
Instead, the local agent that is running this skill performs the review itself and writes the summary directly into sorted_prs.csv.For every PR listed in Generated Files/ReleaseNotes/sorted_prs.csv whose CopilotSummary is empty:
1. Fetch the PR diff using a tool that does not post anything back to GitHub. Any of these works:
mcp_github_pull_request_readwithmethod: get_diffmcp_github_pull_request_readwithmethod: get_files(when the diff is large)gh pr diff <PR_NUMBER> --repo microsoft/PowerToys
2. Read the PR title, body, and diff. Produce a 1–3 sentence, user-facing summary in the same style as a Copilot PR review (focus on observable behavior change, not implementation details). 3. Write the summary into the CopilotSummary column for that PR row in Generated Files/ReleaseNotes/sorted_prs.csv. Preserve all other columns and the existing row order.
Batching guidance
- Process PRs in the order they appear in
sorted_prs.csv. - Generate summaries for all PRs in one pass before continuing to Step 3.3, so the human reviewer can validate them together.
- For very large diffs, summarize from
get_files(filenames + per-file patches) rather than the full diff. - Skip PRs that already have a non-empty
CopilotSummary(e.g. PRs where a human reviewer already pasted one). Do not overwrite existing summaries.
Why not post the summary back to the PR? Posting a comment from the agent's identity would not be picked up by dump-prs-since-commit.ps1 (which only matches Copilot bot authors), and it adds noise to the PR. Writing straight into the CSV keeps the artifact self-contained.
---
3.2 (Optional) Refresh PR Data
Only re-run the collection script if PR metadata on GitHub has changed (new labels, retitled PRs, etc.) since Step 1.1. Skip this step if you only want to preserve the locally generated `CopilotSummary` values from Step 3.1, because re-running the dump will overwrite the CSV.
pwsh ./.github/skills/release-note-generation/scripts/dump-prs-since-commit.ps1 `
-StartCommit '{{PreviousReleaseTag}}' -Branch 'stable' `
-OutputDir 'Generated Files/ReleaseNotes'If you do refresh, redo Step 3.1 afterwards to repopulate CopilotSummary.
---
3.3 Group PRs by Label
pwsh ./.github/skills/release-note-generation/scripts/group-prs-by-label.ps1 -CsvPath 'Generated Files/ReleaseNotes/sorted_prs.csv' -OutDir 'Generated Files/ReleaseNotes/grouped_csv'Creates Generated Files/ReleaseNotes/grouped_csv/ with one CSV per label combination.
Validation: The Unlabeled.csv file should be minimal (ideally empty). If many PRs remain unlabeled, return to Step 2 (see step2-labeling.md).
Step 4: Summaries and Final Release Notes
4.0 To-do
- 4.1 Generate Summary Markdown (Agent Mode)
- 4.2 Produce Final Release Notes File
4.1 Generate Summary Markdown (Agent Mode)
For each CSV in Generated Files/ReleaseNotes/grouped_csv/, create a markdown file in Generated Files/ReleaseNotes/grouped_md/.
⚠️ IMPORTANT: Generate ALL markdown files first. Do NOT pause between files or ask for feedback during generation. Complete the entire batch, then human reviews afterwards.
Structure per file
1. Bullet list - one concise, user-facing line per PR:
- Use the “Verb-ed + Scenario + Impact” sentence structure—make readers think, “That’s exactly what I need” or “Yes, that’s an awesome fix.”; The "impact" can be end-user focused (written to convey user excitement) or technical (performance/stability) when user-facing impact is minimal.
- If nothing special on impact or unclear impact, mark as needing human summary
- Source from Title, Body, and CopilotSummary (prefer CopilotSummary when available)
- The
NeedThankscolumn contains a comma-separated list of external contributor usernames who should be credited (empty = no attribution needed, all authors are core team). For each non-emptyNeedThanksvalue, append abyattribution that lists every contributor, matching GitHub's standard contributor-attribution style:by [@user1](https://github.com/user1)for a single contributor,by [@user1](https://github.com/user1) and [@user2](https://github.com/user2)for two, orby [@user1](https://github.com/user1), [@user2](https://github.com/user2), and [@user3](https://github.com/user3)for three or more. In the final consolidated release notes (Step 4.2), the attribution follows the PR link, e.g.…in [#1234](url) by [@user](url). Do not use "Thanks @user!" phrasing. - Do NOT include PR numbers in bullet lines
- Do NOT mention “security” or “privacy” issues, since these are not known and could be leveraged by attackers in earlier versions. Instead, describe the user-facing scenario, usage, or impact.
- If confidence < 70%, write:
Human Summary Needed: <PR full link>
See [SampleOutput.md](./SampleOutput.md) for examples of well-written bullet summaries.
2. Three-column table (same PR order):
- Column 1: Concise summary (same as bullet)
- Column 2: PR link
[#ID](URL) - Column 3: Confidence level (High/Medium/Low)
Review Process (AFTER all files generated)
- Human reviews each
grouped_md/*.mdfile and requests rewrites as needed - Human may say "rewrite Product-X" or "combine these bullets"—apply changes to that specific file
- Do NOT interrupt generation to ask for feedback
---
4.2 Produce Final Release Notes File
Once all grouped_md/*.md files are reviewed and approved, consolidate into a single release notes file.
Output: Generated Files/ReleaseNotes/v{{ReleaseVersion}}-release-notes.md
Structure
1. Highlights section (top):
- 8-12 bullets covering the most user-visible features and impactful fixes
- Pattern:
**Module**: brief description - Avoid internal refactors; focus on what users will notice
2. Module sections (alphabetical order):
- One section per product (Advanced Paste, Awake, Command Palette, etc.)
- Migrate bullet summaries from the approved
grouped_md/Product-*.mdfiles - One section 'Development' for all the rest summaries from the approved
grouped_md/Area-*.mdfiles - Re-review E2E, group release improvements by section, and move the most important items to the top of each section.
Some items in the Development section may overlap and should be moved to the Module section where more applicable.
Example Final Structure
# PowerToys v{{ReleaseVersion}} Release Notes
## Highlights
- **Command Palette**: Added theme customization and drag-and-drop support
- **Advanced Paste**: Image input for AI, color detection in clipboard history
- **FancyZones**: New CLI tool for command-line layout management
...
---
## Advanced Paste
- Wrapped paste option lists in a single ScrollViewer in [#5678](https://github.com/microsoft/PowerToys/pull/5678)
- Added image input handling for AI-powered transformations in [#5679](https://github.com/microsoft/PowerToys/pull/5679)
...
## Awake
- Fixed timed mode expiration in [#5680](https://github.com/microsoft/PowerToys/pull/5680) by [@daverayment](https://github.com/daverayment)
...
---
## Development
...<#
.SYNOPSIS
Apply labels to PRs from a CSV file.
.DESCRIPTION
Reads a CSV with Id and Label columns and applies the specified label to each PR via GitHub CLI.
Supports dry-run mode to preview changes before applying.
.PARAMETER InputCsv
CSV file with Id and Label columns. Default: prs_to_label.csv
.PARAMETER Repo
GitHub repository (owner/name). Default: microsoft/PowerToys
.PARAMETER WhatIf
Dry run - show what would be applied without making changes.
.EXAMPLE
pwsh ./apply-labels.ps1 -InputCsv 'Generated Files/ReleaseNotes/prs_to_label.csv'
.EXAMPLE
pwsh ./apply-labels.ps1 -InputCsv 'Generated Files/ReleaseNotes/prs_to_label.csv' -WhatIf
.NOTES
Requires: gh CLI authenticated with repo write access.
Input CSV format:
Id,Label
12345,Product-Advanced Paste
12346,Product-Settings
#>
[CmdletBinding()] param(
[Parameter(Mandatory=$false)][string]$InputCsv = 'prs_to_label.csv',
[Parameter(Mandatory=$false)][string]$Repo = 'microsoft/PowerToys',
[switch]$WhatIf
)
$ErrorActionPreference = 'Stop'
function Write-Info($m){ Write-Host "[info] $m" -ForegroundColor Cyan }
function Write-Warn($m){ Write-Host "[warn] $m" -ForegroundColor Yellow }
function Write-Err($m){ Write-Host "[error] $m" -ForegroundColor Red }
function Write-OK($m){ Write-Host "[ok] $m" -ForegroundColor Green }
if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { Write-Err "GitHub CLI 'gh' not found in PATH"; exit 1 }
if (-not (Test-Path -LiteralPath $InputCsv)) { Write-Err "Input CSV not found: $InputCsv"; exit 1 }
$rows = Import-Csv -LiteralPath $InputCsv
if (-not $rows) { Write-Info "No rows in CSV."; exit 0 }
$firstCols = $rows[0].PSObject.Properties.Name
if (-not ($firstCols -contains 'Id' -and $firstCols -contains 'Label')) {
Write-Err "CSV must contain 'Id' and 'Label' columns"; exit 1
}
Write-Info "Processing $($rows.Count) label assignments..."
if ($WhatIf) { Write-Warn "DRY RUN - no changes will be made" }
$applied = 0
$skipped = 0
$failed = 0
foreach ($row in $rows) {
$id = $row.Id
$label = $row.Label
if ([string]::IsNullOrWhiteSpace($id) -or [string]::IsNullOrWhiteSpace($label)) {
Write-Warn "Skipping row with empty Id or Label"
$skipped++
continue
}
if ($WhatIf) {
Write-Info "Would apply label '$label' to PR #$id"
$applied++
continue
}
try {
gh pr edit $id --repo $Repo --add-label $label 2>&1 | Out-Null
Write-OK "Applied '$label' to PR #$id"
$applied++
} catch {
Write-Warn "Failed to apply label to PR #${id}: $_"
$failed++
}
}
Write-Info ""
Write-Info "Summary: Applied=$applied Skipped=$skipped Failed=$failed"
<#
.SYNOPSIS
Collect existing PR milestones or (optionally) assign/apply a milestone to missing PRs in one script.
.DESCRIPTION
This unified script merges the behaviors of the previous add-milestone-column (collector) and
set-milestones-missing (remote updater) scripts.
Modes (controlled by switches):
1. Collect (default) – For each PR Id in the input CSV, queries GitHub for the current milestone and
outputs a two-column CSV (Id,Milestone) leaving blanks where none are set.
2. LocalAssign – Same as Collect, but for rows that end up blank assigns the value of -DefaultMilestone
in memory (does NOT touch GitHub). Useful for quickly preparing a fully populated CSV.
3. ApplyMissing – After determining which PRs have no milestone, call GitHub API to set their milestone
to -DefaultMilestone. Requires milestone to already exist (open). Network + write.
You can combine LocalAssign and ApplyMissing: the remote update uses the existing live state; LocalAssign only
affects the output CSV/pipeline objects.
.PARAMETER InputCsv
Source CSV with at least an Id column. Default: sorted_prs.csv
.PARAMETER OutputCsv
Destination CSV for collected (and optionally locally assigned) milestones. Default: prs_with_milestone.csv
.PARAMETER Repo
GitHub repository (owner/name). Default: microsoft/PowerToys
.PARAMETER DefaultMilestone
Milestone title used when -LocalAssign or -ApplyMissing is specified. Default: 'PowerToys 0.97'
.PARAMETER Offline
Skip ALL GitHub lookups / updates. Implies Collect-only with all Milestone cells blank (unless LocalAssign).
.PARAMETER LocalAssign
Populate empty Milestone cells in the output with -DefaultMilestone (does not modify GitHub).
.PARAMETER ApplyMissing
For PRs which currently have no milestone (live on GitHub), set them to -DefaultMilestone via Issues API.
.PARAMETER WhatIf
Dry run for ApplyMissing: show intended remote changes without performing PATCH requests.
.EXAMPLE
# Collect only
pwsh ./collect-or-apply-milestones.ps1
.EXAMPLE
# Collect and fill blanks locally in the output only
pwsh ./collect-or-apply-milestones.ps1 -LocalAssign
.EXAMPLE
# Collect and remotely apply milestone to missing PRs
pwsh ./collect-or-apply-milestones.ps1 -ApplyMissing
.EXAMPLE
# Dry run remote application
pwsh ./collect-or-apply-milestones.ps1 -ApplyMissing -WhatIf
.EXAMPLE
# Offline local assignment
pwsh ./collect-or-apply-milestones.ps1 -Offline -LocalAssign -DefaultMilestone 'PowerToys 0.96'
.NOTES
Requires gh CLI unless -Offline AND -ApplyMissing not specified.
Remote apply path queries milestones to resolve numeric ID.
#>
[CmdletBinding()] param(
[Parameter(Mandatory=$false)][string]$InputCsv = 'sorted_prs.csv',
[Parameter(Mandatory=$false)][string]$OutputCsv = 'prs_with_milestone.csv',
[Parameter(Mandatory=$false)][string]$Repo = 'microsoft/PowerToys',
[Parameter(Mandatory=$false)][string]$DefaultMilestone = 'PowerToys 0.97',
[switch]$Offline,
[switch]$LocalAssign,
[switch]$ApplyMissing,
[switch]$WhatIf
)
$ErrorActionPreference = 'Stop'
function Write-Info($m){ Write-Host "[info] $m" -ForegroundColor Cyan }
function Write-Warn($m){ Write-Host "[warn] $m" -ForegroundColor Yellow }
function Write-Err($m){ Write-Host "[error] $m" -ForegroundColor Red }
if (-not (Test-Path -LiteralPath $InputCsv)) { Write-Err "Input CSV not found: $InputCsv"; exit 1 }
$rows = Import-Csv -LiteralPath $InputCsv
if (-not $rows) { Write-Warn "Input CSV has no rows."; @() | Export-Csv -NoTypeInformation -LiteralPath $OutputCsv; exit 0 }
if (-not ($rows[0].PSObject.Properties.Name -contains 'Id')) { Write-Err "Input CSV missing 'Id' column."; exit 1 }
$needGh = (-not $Offline) -and ($ApplyMissing -or -not $Offline)
if ($needGh -and -not (Get-Command gh -ErrorAction SilentlyContinue)) { Write-Err "GitHub CLI 'gh' not found. Use -Offline or install gh."; exit 1 }
# Step 1: Collect current milestone titles
$milestoneCache = @{}
$collected = New-Object System.Collections.Generic.List[object]
$idx = 0
foreach ($row in $rows) {
$idx++
$id = $row.Id
if (-not $id) { Write-Warn "Row $idx missing Id; skipping"; continue }
$ms = ''
if (-not $Offline) {
if ($milestoneCache.ContainsKey($id)) { $ms = $milestoneCache[$id] }
else {
try {
$json = gh pr view $id --repo $Repo --json milestone 2>$null | ConvertFrom-Json
if ($json -and $json.milestone -and $json.milestone.title) { $ms = $json.milestone.title }
} catch {
Write-Warn "Failed to fetch PR #$id milestone: $_"
}
$milestoneCache[$id] = $ms
}
}
$collected.Add([PSCustomObject]@{ Id = $id; Milestone = $ms }) | Out-Null
}
# Step 2: Remote apply (if requested)
$applySummary = @()
if ($ApplyMissing) {
if ($Offline) { Write-Err "Cannot use -ApplyMissing with -Offline."; exit 1 }
Write-Info "Resolving milestone id for '$DefaultMilestone' ..."
$milestonesRaw = gh api repos/$Repo/milestones --paginate --jq '.[] | {number,title,state}'
$msObj = $milestonesRaw | ConvertFrom-Json | Where-Object { $_.title -eq $DefaultMilestone -and $_.state -eq 'open' } | Select-Object -First 1
if (-not $msObj) { Write-Err "Milestone '$DefaultMilestone' not found/open."; exit 1 }
$msNumber = $msObj.number
$targets = $collected | Where-Object { [string]::IsNullOrWhiteSpace($_.Milestone) }
Write-Info ("ApplyMissing: {0} PR(s) without milestone." -f $targets.Count)
foreach ($t in $targets) {
$id = $t.Id
try {
# Verify still missing live
$current = gh pr view $id --repo $Repo --json milestone --jq '.milestone.title // ""'
if ($current) {
$applySummary += [PSCustomObject]@{ Id=$id; Action='Skip (already has)'; Milestone=$current; Status='OK' }
continue
}
if ($WhatIf) {
$applySummary += [PSCustomObject]@{ Id=$id; Action='Would set'; Milestone=$DefaultMilestone; Status='DRY RUN' }
continue
}
gh api -X PATCH -H 'Accept: application/vnd.github+json' repos/$Repo/issues/$id -f milestone=$msNumber | Out-Null
$applySummary += [PSCustomObject]@{ Id=$id; Action='Set'; Milestone=$DefaultMilestone; Status='OK' }
# Reflect in collected object for CSV output if not LocalAssign already doing so
$t.Milestone = $DefaultMilestone
} catch {
$errText = $_ | Out-String
$applySummary += [PSCustomObject]@{ Id=$id; Action='Failed'; Milestone=$DefaultMilestone; Status=$errText.Trim() }
Write-Warn ("Failed to set milestone for PR #{0}: {1}" -f $id, ($errText.Trim()))
}
}
}
# Step 3: Local assignment (purely for output) AFTER remote so remote actual result not overwritten accidentally
if ($LocalAssign) {
foreach ($item in $collected) {
if ([string]::IsNullOrWhiteSpace($item.Milestone)) { $item.Milestone = $DefaultMilestone }
}
}
# Step 4: Export CSV
$collected | Export-Csv -LiteralPath $OutputCsv -NoTypeInformation -Encoding UTF8
Write-Info ("Wrote collected CSV -> {0}" -f (Resolve-Path -LiteralPath $OutputCsv))
# Step 5: Summaries
if ($ApplyMissing) {
$updated = ($applySummary | Where-Object { $_.Action -eq 'Set' }).Count
$skipped = ($applySummary | Where-Object { $_.Action -like 'Skip*' }).Count
$failed = ($applySummary | Where-Object { $_.Action -eq 'Failed' }).Count
Write-Info ("ApplyMissing summary: Updated={0} Skipped={1} Failed={2}" -f $updated, $skipped, $failed)
}
# Emit objects (final collected set)
return $collected
<#
.SYNOPSIS
Produce an incremental PR CSV containing rows present in a newer full export but absent from a baseline export.
.DESCRIPTION
Compares two previously generated sorted PR CSV files (same schema). Any row whose key column value
(defaults to 'Number') does not exist in the baseline file is emitted to a new incremental CSV, preserving
the original column order. If no new rows are found, an empty CSV (with headers when determinable) is written.
.PARAMETER BaseCsv
Path to the baseline (earlier) PR CSV.
.PARAMETER AllCsv
Path to the newer full PR CSV containing superset (or equal set) of rows.
.PARAMETER OutCsv
Path to write the incremental CSV containing only new rows.
.PARAMETER Key
Column name used as unique identifier (defaults to 'Number'). Must exist in both CSVs.
.EXAMPLE
pwsh ./diff_prs.ps1 -BaseCsv sorted_prs_prev.csv -AllCsv sorted_prs.csv -OutCsv sorted_prs_incremental.csv
.NOTES
Requires: PowerShell 7+, both CSVs with identical column schemas.
Exit code 0 on success (even if zero incremental rows). Throws on missing files.
#>
[CmdletBinding()] param(
[Parameter(Mandatory=$false)][string]$BaseCsv = "./sorted_prs_93_round1.csv",
[Parameter(Mandatory=$false)][string]$AllCsv = "./sorted_prs.csv",
[Parameter(Mandatory=$false)][string]$OutCsv = "./sorted_prs_93_incremental.csv",
[Parameter(Mandatory=$false)][string]$Key = "Number"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Write-Info($m) { Write-Host "[info] $m" -ForegroundColor Cyan }
function Write-Warn($m) { Write-Host "[warn] $m" -ForegroundColor Yellow }
if (-not (Test-Path -LiteralPath $BaseCsv)) { throw "Base CSV not found: $BaseCsv" }
if (-not (Test-Path -LiteralPath $AllCsv)) { throw "All CSV not found: $AllCsv" }
# Load CSVs
$baseRows = Import-Csv -LiteralPath $BaseCsv
$allRows = Import-Csv -LiteralPath $AllCsv
if (-not $baseRows) { Write-Warn "Base CSV has no rows." }
if (-not $allRows) { Write-Warn "All CSV has no rows." }
# Validate key presence
if ($baseRows -and -not ($baseRows[0].PSObject.Properties.Name -contains $Key)) { throw "Key column '$Key' not found in base CSV." }
if ($allRows -and -not ($allRows[0].PSObject.Properties.Name -contains $Key)) { throw "Key column '$Key' not found in all CSV." }
# Build a set of existing keys from base
$set = New-Object 'System.Collections.Generic.HashSet[string]'
foreach ($row in $baseRows) {
$val = [string]($row.$Key)
if ($null -ne $val) { [void]$set.Add($val) }
}
# Filter rows in AllCsv whose key is not in base (these are the new / incremental rows)
$incremental = @()
foreach ($row in $allRows) {
$val = [string]($row.$Key)
if (-not $set.Contains($val)) { $incremental += $row }
}
# Preserve column order from the All CSV
$columns = @()
if ($allRows.Count -gt 0) {
$columns = $allRows[0].PSObject.Properties.Name
}
try {
if ($incremental.Count -gt 0) {
if ($columns.Count -gt 0) {
$incremental | Select-Object -Property $columns | Export-Csv -LiteralPath $OutCsv -NoTypeInformation -Encoding UTF8
} else {
$incremental | Export-Csv -LiteralPath $OutCsv -NoTypeInformation -Encoding UTF8
}
} else {
# Write an empty CSV with headers if we know them (facilitates downstream tooling expecting header row)
if ($columns.Count -gt 0) {
$obj = [PSCustomObject]@{}
foreach ($c in $columns) { $obj | Add-Member -NotePropertyName $c -NotePropertyValue $null }
$obj | Select-Object -Property $columns | Export-Csv -LiteralPath $OutCsv -NoTypeInformation -Encoding UTF8
} else {
'' | Out-File -LiteralPath $OutCsv -Encoding UTF8
}
}
Write-Info ("Incremental rows: {0}" -f $incremental.Count)
Write-Info ("Output: {0}" -f (Resolve-Path -LiteralPath $OutCsv))
}
catch {
Write-Host "[error] Failed writing output CSV: $_" -ForegroundColor Red
exit 1
}
<#
.SYNOPSIS
Export merged PR metadata between two commits (exclusive start, inclusive end) to JSON and CSV.
.DESCRIPTION
Identifies merge/squash commits reachable from EndCommit but not StartCommit, extracts PR numbers,
queries GitHub for metadata plus (optionally) Copilot review/comment summaries, filters labels, then
emits a JSON artifact and a sorted CSV (first label alphabetical).
.PARAMETER StartCommit
Exclusive starting commit (SHA, tag, or ref). Commits AFTER this one are considered.
.PARAMETER EndCommit
Inclusive ending commit (SHA, tag, or ref). If not provided, uses origin/<Branch> when Branch is set; otherwise uses HEAD.
.PARAMETER Repo
GitHub repository (owner/name). Default: microsoft/PowerToys.
.PARAMETER OutputCsv
Destination CSV path. Default: sorted_prs.csv.
.PARAMETER OutputJson
Destination JSON path containing raw PR objects. Default: milestone_prs.json.
.EXAMPLE
pwsh ./dump-prs-since-commit.ps1 -StartCommit 0123abcd -Branch stable
.EXAMPLE
pwsh ./dump-prs-since-commit.ps1 -StartCommit 0123abcd -EndCommit 89ef7654 -OutputCsv delta.csv
.NOTES
Requires: git, gh (authenticated). No Set-StrictMode to keep parity with existing release scripts.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$StartCommit, # exclusive start (commits AFTER this one)
[string]$EndCommit,
[string]$Branch,
[string]$Repo = "microsoft/PowerToys",
[string]$OutputDir,
[string]$OutputCsv = "sorted_prs.csv",
[string]$OutputJson = "milestone_prs.json"
)
# (See top-level synopsis above for full documentation)
function Write-Info($msg) { Write-Host $msg -ForegroundColor Cyan }
function Write-Warn($msg) { Write-Host $msg -ForegroundColor Yellow }
function Write-Err($msg) { Write-Host $msg -ForegroundColor Red }
function Write-DebugMsg($msg) { if ($PSBoundParameters.ContainsKey('Verbose') -or $VerbosePreference -eq 'Continue') { Write-Host "[VERBOSE] $msg" -ForegroundColor DarkGray } }
# Load member list from Generated Files/ReleaseNotes/MemberList.md (internal team - no thanks needed)
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Resolve-Path (Join-Path $scriptDir "..\..\..\..")
$defaultMemberListPath = Join-Path $repoRoot "Generated Files\ReleaseNotes\MemberList.md"
$memberListPath = $defaultMemberListPath
if ($OutputDir) {
$memberListFromOutputDir = Join-Path $OutputDir "MemberList.md"
if (Test-Path $memberListFromOutputDir) {
$memberListPath = $memberListFromOutputDir
}
}
$memberList = @()
if (Test-Path $memberListPath) {
$memberListContent = Get-Content $memberListPath -Raw
# Extract usernames - skip markdown code fence lines, get all non-empty lines
$memberList = ($memberListContent -split "`n") | Where-Object { $_ -notmatch '^\s*```' -and $_.Trim() -ne '' } | ForEach-Object { $_.Trim() }
if (-not $memberList -or $memberList.Count -eq 0) {
Write-Err "MemberList.md is empty at $memberListPath"
exit 1
}
Write-DebugMsg "Loaded $($memberList.Count) members from MemberList.md"
} else {
Write-Err "MemberList.md not found at $memberListPath"
exit 1
}
# Validate we are in a git repo
#if (-not (Test-Path .git)) {
# Write-Err "Current directory does not appear to be the root of a git repository."
# exit 1
#}
# Resolve output directory (if specified)
if ($OutputDir) {
if (-not (Test-Path $OutputDir)) {
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
}
if (-not [System.IO.Path]::IsPathRooted($OutputCsv)) {
$OutputCsv = Join-Path $OutputDir $OutputCsv
}
if (-not [System.IO.Path]::IsPathRooted($OutputJson)) {
$OutputJson = Join-Path $OutputDir $OutputJson
}
}
# Resolve commits
try {
if ($Branch) {
Write-Info "Fetching latest '$Branch' from origin (with tags)..."
git fetch origin $Branch --tags | Out-Null
if ($LASTEXITCODE -ne 0) { throw "git fetch origin $Branch --tags failed" }
}
$startSha = (git rev-parse --verify $StartCommit) 2>$null
if (-not $startSha) { throw "StartCommit '$StartCommit' not found" }
if ($Branch) {
$branchRef = $Branch
$branchSha = (git rev-parse --verify $branchRef) 2>$null
if (-not $branchSha) {
$branchRef = "origin/$Branch"
$branchSha = (git rev-parse --verify $branchRef) 2>$null
}
if (-not $branchSha) { throw "Branch '$Branch' not found" }
if (-not $PSBoundParameters.ContainsKey('EndCommit') -or [string]::IsNullOrWhiteSpace($EndCommit)) {
$EndCommit = $branchRef
}
}
if (-not $PSBoundParameters.ContainsKey('EndCommit') -or [string]::IsNullOrWhiteSpace($EndCommit)) {
$EndCommit = "HEAD"
}
$endSha = (git rev-parse --verify $EndCommit) 2>$null
if (-not $endSha) { throw "EndCommit '$EndCommit' not found" }
}
catch {
Write-Err $_
exit 1
}
Write-Info "Collecting commits between $startSha..$endSha (excluding start, including end)."
# Get list of commits reachable from end but not from start.
# IMPORTANT: In PowerShell, the .. operator creates a numeric/char range. If $startSha and $endSha look like hex strings,
# `$startSha..$endSha` must be passed as a single string argument.
$rangeArg = "$startSha..$endSha"
$commitList = git rev-list $rangeArg
# Normalize list (filter out empty strings)
$normalizedCommits = $commitList | Where-Object { $_ -and $_.Trim() -ne '' }
$commitCount = ($normalizedCommits | Measure-Object).Count
Write-DebugMsg ("Raw commitList length (including blanks): {0}" -f (($commitList | Measure-Object).Count))
Write-DebugMsg ("Normalized commit count: {0}" -f $commitCount)
if ($commitCount -eq 0) {
Write-Warn "No commits found in specified range ($startSha..$endSha)."; exit 0
}
Write-DebugMsg ("First 5 commits: {0}" -f (($normalizedCommits | Select-Object -First 5) -join ', '))
<#
Extract PR numbers from commits.
Patterns handled:
1. Merge commits: 'Merge pull request #12345 from ...'
2. Squash commits: 'Some feature change (#12345)' (GitHub default squash format)
We collect both. If a commit matches both (unlikely), it's deduped later.
#>
# Extract PR numbers from merge or squash commits
$mergeCommits = @()
foreach ($c in $normalizedCommits) {
$subject = git show -s --format=%s $c
$matched = $false
# Pattern 1: Traditional merge commit
if ($subject -match 'Merge pull request #([0-9]+) ') {
$prNumber = [int]$matches[1]
$mergeCommits += [PSCustomObject]@{ Sha = $c; Pr = $prNumber; Subject = $subject; Pattern = 'merge' }
Write-DebugMsg "Matched merge PR #$prNumber in commit $c"
$matched = $true
}
# Pattern 2: Squash merge subject line with ' (#12345)' at end (allow possible whitespace before paren)
if ($subject -match '\(#([0-9]+)\)$') {
$prNumber2 = [int]$matches[1]
# Avoid duplicate object if pattern 1 already captured same number for same commit
if (-not ($mergeCommits | Where-Object { $_.Sha -eq $c -and $_.Pr -eq $prNumber2 })) {
$mergeCommits += [PSCustomObject]@{ Sha = $c; Pr = $prNumber2; Subject = $subject; Pattern = 'squash' }
Write-DebugMsg "Matched squash PR #$prNumber2 in commit $c"
}
$matched = $true
}
if (-not $matched) {
Write-DebugMsg "No PR pattern in commit $c : $subject"
}
}
if (-not $mergeCommits -or $mergeCommits.Count -eq 0) {
Write-Warn "No merge commits with PR numbers found in range."; exit 0
}
# Deduplicate PR numbers (in case of revert or merges across branches)
$prNumbers = $mergeCommits | Select-Object -ExpandProperty Pr -Unique | Sort-Object
Write-Info ("Found {0} unique PRs: {1}" -f $prNumbers.Count, ($prNumbers -join ', '))
Write-DebugMsg ("Total merge commits examined: {0}" -f $mergeCommits.Count)
# Build a map of PR number → list of commit SHAs (for co-author extraction)
$prToCommits = @{}
foreach ($mc in $mergeCommits) {
if (-not $prToCommits.ContainsKey($mc.Pr)) {
$prToCommits[$mc.Pr] = @()
}
$prToCommits[$mc.Pr] += $mc.Sha
}
<#
.SYNOPSIS
Get all authors (including co-authors) for a set of commits via GitHub GraphQL API.
.DESCRIPTION
Uses the Commit.authors field in GitHub's GraphQL API which natively includes
co-authors (from Co-authored-by trailers). Returns GitHub usernames (login)
without any email parsing — GitHub resolves the association for us.
NOTE: For squash merges this captures all co-authors correctly because GitHub
preserves Co-authored-by trailers in the squash commit. For traditional merge
commits, only the merger's author is returned — co-authors on individual PR
commits are not traversed. This is acceptable because PowerToys primarily uses
squash merging.
#>
function Get-CommitAuthors {
param(
[string[]]$CommitShas,
[string]$RepoFullName = "microsoft/PowerToys"
)
$parts = $RepoFullName -split '/'
$owner = $parts[0]
$repoName = $parts[1]
$allAuthors = @()
foreach ($sha in $CommitShas) {
try {
$query = "{ repository(owner: `"$owner`", name: `"$repoName`") { object(expression: `"$sha`") { ... on Commit { authors(first: 20) { nodes { user { login } name } } } } } }"
$result = gh api graphql -f query="$query" 2>$null | ConvertFrom-Json
$nodes = $result.data.repository.object.authors.nodes
if ($nodes) {
foreach ($node in $nodes) {
if ($node.user -and $node.user.login) {
$allAuthors += $node.user.login
} else {
# User without a GitHub account (rare) — use display name as fallback
Write-DebugMsg "Commit $sha has an author without GitHub account: $($node.name)"
}
}
}
}
catch {
Write-DebugMsg "GraphQL authors query failed for commit ${sha}: $_"
}
}
return $allAuthors | Select-Object -Unique
}
# Query GitHub for each PR
$prDetails = @()
function Get-CopilotSummaryFromPrJson {
param(
[Parameter(Mandatory=$true)]$PrJson,
[switch]$VerboseMode
)
# Returns a hashtable with Summary and Source keys.
$result = @{ Summary = ""; Source = "" }
if (-not $PrJson) { return $result }
$candidateAuthors = @(
'github-copilot[bot]', 'github-copilot', 'copilot'
)
# 1. Reviews (preferred) – pick the LONGEST valid Copilot body, not the most recent
$reviews = $PrJson.reviews
if ($reviews) {
$copilotReviews = $reviews | Where-Object {
($candidateAuthors -contains $_.author.login -or $_.author.login -like '*copilot*') -and $_.body -and $_.body.Trim() -ne ''
}
if ($copilotReviews) {
$longest = $copilotReviews | Sort-Object { $_.body.Length } -Descending | Select-Object -First 1
if ($longest) {
$body = $longest.body
$norm = ($body -replace "`r", '') -replace "`n", ' '
$norm = $norm -replace '\s+', ' '
$result.Summary = $norm
$result.Source = 'review'
if ($VerboseMode) { Write-DebugMsg "Selected Copilot review length=$($body.Length) (longest)." }
return $result
}
}
}
# 2. Comments fallback (some repos surface Copilot summaries as PR comments rather than review objects)
if ($null -eq $PrJson.comments) {
try {
# Lazy fetch comments only if needed
$commentsJson = gh pr view $PrJson.number --repo $Repo --json comments 2>$null | ConvertFrom-Json
if ($commentsJson -and $commentsJson.comments) {
$PrJson | Add-Member -NotePropertyName comments -NotePropertyValue $commentsJson.comments -Force
}
} catch {
if ($VerboseMode) { Write-DebugMsg "Failed to fetch comments for PR #$($PrJson.number): $_" }
}
}
if ($PrJson.comments) {
$copilotComments = $PrJson.comments | Where-Object {
($candidateAuthors -contains $_.author.login -or $_.author.login -like '*copilot*') -and $_.body -and $_.body.Trim() -ne ''
}
if ($copilotComments) {
$longestC = $copilotComments | Sort-Object { $_.body.Length } -Descending | Select-Object -First 1
if ($longestC) {
$body = $longestC.body
$norm = ($body -replace "`r", '') -replace "`n", ' '
$norm = $norm -replace '\s+', ' '
$result.Summary = $norm
$result.Source = 'comment'
if ($VerboseMode) { Write-DebugMsg "Selected Copilot comment length=$($body.Length) (longest)." }
return $result
}
}
}
return $result
}
foreach ($pr in $prNumbers) {
Write-Info "Fetching PR #$pr ..."
try {
# Include comments only if Verbose asked; if not, we lazily pull when reviews are missing
$fields = 'number,title,labels,author,url,body,reviews'
if ($PSBoundParameters.ContainsKey('Verbose')) { $fields += ',comments' }
$json = gh pr view $pr --repo $Repo --json $fields 2>$null | ConvertFrom-Json
if ($null -eq $json) { throw "Empty response" }
$copilot = Get-CopilotSummaryFromPrJson -PrJson $json -VerboseMode:($PSBoundParameters.ContainsKey('Verbose'))
if ($copilot.Summary -and $copilot.Source -and $PSBoundParameters.ContainsKey('Verbose')) {
Write-DebugMsg "Copilot summary source=$($copilot.Source) chars=$($copilot.Summary.Length)"
} elseif (-not $copilot.Summary) {
Write-DebugMsg "No Copilot summary found for PR #$pr"
}
# Filter labels
$filteredLabels = $json.labels | Where-Object {
($_.name -like "Product-*") -or
($_.name -like "Area-*") -or
($_.name -like "GitHub*") -or
($_.name -like "*Plugin") -or
($_.name -like "Issue-*")
}
$labelNames = ($filteredLabels | ForEach-Object { $_.name }) -join ", "
$bodyValue = if ($json.body) { ($json.body -replace "`r", '') -replace "`n", ' ' } else { '' }
$bodyValue = $bodyValue -replace '\s+', ' '
# Collect all contributors: PR author + co-authors from commit messages
$authorLogin = $json.author.login
$allContributors = @($authorLogin)
# Extract all authors (including co-authors) from associated commits via GitHub GraphQL API
if ($prToCommits.ContainsKey([int]$pr)) {
$commitAuthors = Get-CommitAuthors -CommitShas $prToCommits[[int]$pr] -RepoFullName $Repo
if ($commitAuthors) {
$allContributors += $commitAuthors
}
}
# Deduplicate contributors (case-insensitive)
$allContributors = $allContributors | Where-Object { $_ } | Sort-Object -Unique
# Filter to only external contributors (not in member list) for thanks
$externalContributors = @()
if ($memberList.Count -gt 0) {
$externalContributors = $allContributors | Where-Object { -not ($memberList -contains $_) }
} else {
$externalContributors = $allContributors
}
# Author column: all contributors (comma-separated)
$authorField = ($allContributors -join ', ')
# NeedThanks column: comma-separated list of external contributors who
# deserve thanks attribution. Empty string means no thanks needed.
$needThanksField = ($externalContributors -join ', ')
$prDetails += [PSCustomObject]@{
Id = $json.number
Title = $json.title
Labels = $labelNames
Author = $authorField
Url = $json.url
Body = $bodyValue
CopilotSummary = $copilot.Summary
NeedThanks = $needThanksField
}
}
catch {
$err = $_
Write-Warn ("Failed to fetch PR #{0}: {1}" -f $pr, $err)
}
}
if (-not $prDetails) { Write-Warn "No PR details fetched."; exit 0 }
# Sort by Labels like original script (first label alphabetical)
$sorted = $prDetails | Sort-Object { ($_.Labels -split ',')[0] }
# Output JSON raw (optional)
$sorted | ConvertTo-Json -Depth 6 | Out-File -Encoding UTF8 $OutputJson
Write-Info "Saving CSV to $OutputCsv ..."
$sorted | Export-Csv $OutputCsv -NoTypeInformation
Write-Host "✅ Done. Generated $($prDetails.Count) PR rows." -ForegroundColor Green
<#
.SYNOPSIS
Find a commit on a branch that has the same subject line as a reference commit.
.DESCRIPTION
Given a commit SHA (often from a release tag) and a branch name, this script
resolves the reference commit's subject, then searches the branch history for
commits with the exact same subject line. Useful when the release tag commit
is not reachable from your current branch history.
.PARAMETER Commit
The reference commit SHA or ref (e.g., v0.96.1 or a full SHA).
.PARAMETER Branch
The branch to search (e.g., stable or main). Defaults to stable.
.PARAMETER RepoPath
Path to the local repo. Defaults to current directory.
.EXAMPLE
pwsh ./find-commit-by-title.ps1 -Commit b62f6421845f7e5c92b8186868d98f46720db442 -Branch stable
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Commit,
[string]$Branch = "stable",
[string]$RepoPath = "."
)
function Write-Info($msg) { Write-Host $msg -ForegroundColor Cyan }
function Write-Err($msg) { Write-Host $msg -ForegroundColor Red }
Push-Location $RepoPath
try {
Write-Info "Fetching latest '$Branch' from origin (with tags)..."
git fetch origin $Branch --tags | Out-Null
if ($LASTEXITCODE -ne 0) { throw "git fetch origin $Branch --tags failed" }
$commitSha = (git rev-parse --verify $Commit) 2>$null
if (-not $commitSha) { throw "Commit '$Commit' not found" }
$subject = (git show -s --format=%s $commitSha) 2>$null
if (-not $subject) { throw "Unable to read subject for '$commitSha'" }
$branchRef = $Branch
$branchSha = (git rev-parse --verify $branchRef) 2>$null
if (-not $branchSha) {
$branchRef = "origin/$Branch"
$branchSha = (git rev-parse --verify $branchRef) 2>$null
}
if (-not $branchSha) { throw "Branch '$Branch' not found" }
Write-Info "Reference commit: $commitSha"
Write-Info "Reference title: $subject"
Write-Info "Searching branch: $branchRef"
$matches = git log $branchRef --format="%H|%s" | Where-Object { $_ -match '\|' }
$results = @()
foreach ($line in $matches) {
$parts = $line -split '\|', 2
if ($parts.Count -eq 2 -and $parts[1] -eq $subject) {
$results += [PSCustomObject]@{ Sha = $parts[0]; Title = $parts[1] }
}
}
if (-not $results -or $results.Count -eq 0) {
Write-Info "No matching commit found on $branchRef for the given title."
exit 0
}
Write-Info ("Found {0} matching commit(s):" -f $results.Count)
$results | ForEach-Object { Write-Host ("{0} {1}" -f $_.Sha, $_.Title) }
}
catch {
Write-Err $_
exit 1
}
finally {
Pop-Location
}
<#
.SYNOPSIS
Group PR rows by their Labels column and emit per-label CSV files.
.DESCRIPTION
Reads a milestone PR CSV (usually produced by dump-prs-information / dump-prs-since-commit scripts),
splits rows by label list, normalizes/sorts individual labels, and writes one CSV per unique label combination.
Each output preserves the original row ordering within that subset and column order from the source.
.PARAMETER CsvPath
Input CSV containing PR rows with a 'Labels' column (comma-separated list).
.PARAMETER OutDir
Output directory to place grouped CSVs (created if missing). Default: 'grouped_csv'.
.NOTES
Label combinations are joined using ' | ' when multiple labels present. Filenames are sanitized (invalid characters,
whitespace collapsed) and truncated to <= 120 characters.
#>
param(
[string]$CsvPath = "sorted_prs.csv",
[string]$OutDir = "grouped_csv"
)
$ErrorActionPreference = 'Stop'
function Write-Info($msg) { Write-Host "[info] $msg" -ForegroundColor Cyan }
function Write-Warn($msg) { Write-Host "[warn] $msg" -ForegroundColor Yellow }
if (-not (Test-Path -LiteralPath $CsvPath)) { throw "CSV not found: $CsvPath" }
Write-Info "Reading CSV: $CsvPath"
$rows = Import-Csv -LiteralPath $CsvPath
Write-Info ("Loaded {0} rows" -f $rows.Count)
function ConvertTo-SafeFileName {
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)][string]$Name
)
if ([string]::IsNullOrWhiteSpace($Name)) { return 'Unnamed' }
$s = $Name -replace '[<>:"/\\|?*]', '-' # invalid path chars
$s = $s -replace '\s+', '-' # spaces to dashes
$s = $s -replace '-{2,}', '-' # collapse dashes
$s = $s.Trim('-')
if ($s.Length -gt 120) { $s = $s.Substring(0,120).Trim('-') }
if ([string]::IsNullOrWhiteSpace($s)) { return 'Unnamed' }
return $s
}
# Build groups keyed by normalized, sorted label combinations. Preserve original CSV row order.
$groups = @{}
foreach ($row in $rows) {
$labelsRaw = $row.Labels
if ([string]::IsNullOrWhiteSpace($labelsRaw)) {
$labelParts = @('Unlabeled')
} else {
$parts = $labelsRaw -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
if (-not $parts -or $parts.Count -eq 0) { $labelParts = @('Unlabeled') }
else { $labelParts = $parts | Sort-Object }
}
$key = ($labelParts -join ' | ')
if (-not $groups.ContainsKey($key)) { $groups[$key] = New-Object System.Collections.ArrayList }
[void]$groups[$key].Add($row)
}
if (-not (Test-Path -LiteralPath $OutDir)) {
Write-Info "Creating output directory: $OutDir"
New-Item -ItemType Directory -Path $OutDir | Out-Null
}
Write-Info ("Generating {0} grouped CSV file(s) into: {1}" -f $groups.Count, $OutDir)
foreach ($key in $groups.Keys) {
$labelParts = if ($key -eq 'Unlabeled') { @('Unlabeled') } else { $key -split '\s\|\s' }
$safeName = ($labelParts | ForEach-Object { ConvertTo-SafeFileName -Name $_ }) -join '-'
$filePath = Join-Path $OutDir ("$safeName.csv")
# Keep same columns and order
$groups[$key] | Export-Csv -LiteralPath $filePath -NoTypeInformation -Encoding UTF8
}
Write-Info "Done. Sample output files:"
Get-ChildItem -LiteralPath $OutDir | Select-Object -First 10 Name | Format-Table -HideTableHeaders
<#
.SYNOPSIS
Prepares the binary assets for a PowerToys GitHub release: downloads the
four installers (per-user/per-machine x x64/arm64) and the symbol archives
from an ADO pipeline build, computes SHA256 hashes, and emits the
"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.
Requires: az login (Azure CLI authenticated), az devops extension.
.EXAMPLE
.\prepare-release-assets.ps1 -BuildId 145505247
.\prepare-release-assets.ps1 -BuildId 145505247 -OutputFolder D:\Releases
#>
param(
[Parameter(Mandatory = $true)]
[int]$BuildId,
[string]$OutputFolder = "$env:USERPROFILE\Downloads",
[string]$Organization = "https://dev.azure.com/microsoft",
[string]$Project = "Dart",
[string]$GitHubRepo = "microsoft/PowerToys"
)
$ErrorActionPreference = "Stop"
$env:AZURE_CORE_NO_PROMPT = "true"
# --- Helpers -----------------------------------------------------------------
# Invoke an `az` CLI command and capture stderr in $script:LastAzError so
# callers can surface the underlying message (expired login, blocked extension,
# tenant policy, ...) instead of swallowing it with `2>$null`.
function Invoke-Az {
$tmpErr = [System.IO.Path]::GetTempFileName()
try {
$output = & az @args 2>$tmpErr
# Get-Content -Raw returns $null for an empty file, and calling .Trim()
# on $null throws under $ErrorActionPreference = 'Stop' -- which would
# turn every successful (no-stderr) az call into a fatal error. Guard
# explicitly so $script:LastAzError is always a (possibly empty) string.
$rawErr = Get-Content $tmpErr -Raw -ErrorAction SilentlyContinue
$script:LastAzError = if ($null -eq $rawErr) { '' } else { $rawErr.Trim() }
return $output
}
finally {
Remove-Item $tmpErr -Force -ErrorAction SilentlyContinue
}
}
# Build an ADO artifact download URL from scratch instead of regex-replacing
# the URL returned by `az pipelines runs artifact list`. Preserves any other
# query parameters and only swaps `format` and `subPath`, so we don't break if
# the upstream URL shape ever changes.
function Get-ArtifactDownloadUrl {
param(
[Parameter(Mandatory)][string]$BaseUrl,
[Parameter(Mandatory)][string]$SubPath,
[Parameter(Mandatory)][ValidateSet('file', 'zip')][string]$Format
)
$encodedSubPath = [Uri]::EscapeDataString($SubPath)
$idx = $BaseUrl.IndexOf('?')
if ($idx -lt 0) {
return "${BaseUrl}?format=${Format}&subPath=${encodedSubPath}"
}
$base = $BaseUrl.Substring(0, $idx)
$kept = $BaseUrl.Substring($idx + 1) -split '&' | Where-Object {
$_ -and -not ($_ -match '^(format|subPath)=')
}
$kept = @($kept) + @("format=$Format", "subPath=$encodedSubPath")
return "${base}?$($kept -join '&')"
}
# Download a single ADO artifact file with bearer auth and a small retry/backoff
# loop. A transient network blip on a ~200 MB installer or symbol zip otherwise
# aborts the entire release-prep run.
function Invoke-AdoDownload {
param(
[Parameter(Mandatory)][string]$Url,
[Parameter(Mandatory)][string]$DestPath,
[Parameter(Mandatory)][string]$Token,
[int]$MaxAttempts = 3
)
$lastError = $null
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
$webClient = New-Object System.Net.WebClient
$webClient.Headers.Add("Authorization", "Bearer $Token")
try {
$webClient.DownloadFile($Url, $DestPath)
return
}
catch {
$lastError = $_
if (Test-Path $DestPath) {
Remove-Item $DestPath -Force -ErrorAction SilentlyContinue
}
if ($attempt -lt $MaxAttempts) {
$backoffSec = [int][Math]::Pow(2, $attempt) # 2, 4, 8 ...
Write-Host " Attempt $attempt failed: $($_.Exception.Message). Retrying in ${backoffSec}s..." -ForegroundColor Yellow
Start-Sleep -Seconds $backoffSec
}
}
finally {
$webClient.Dispose()
}
}
throw "Download failed after $MaxAttempts attempts. Last error: $($lastError.Exception.Message)`nURL: $Url"
}
# -----------------------------------------------------------------------------
# Work around broken az extensions: if the default extension dir has
# inaccessible files, redirect to a clean directory.
$defaultExtDir = "$env:USERPROFILE\.azure\cliextensions"
if (-not $env:AZURE_EXTENSION_DIR -and (Test-Path $defaultExtDir)) {
$broken = Get-ChildItem "$defaultExtDir\*\*.dist-info" -Directory -ErrorAction SilentlyContinue | Where-Object {
try { [System.IO.Directory]::GetFiles($_.FullName) | Out-Null; $false } catch { $true }
}
if ($broken) {
$cleanDir = "$env:USERPROFILE\.azure\cliextensions_clean"
Write-Host " Detected broken az extension, redirecting to $cleanDir" -ForegroundColor Yellow
$env:AZURE_EXTENSION_DIR = $cleanDir
if (-not (Test-Path $cleanDir)) { New-Item -ItemType Directory -Path $cleanDir -Force | Out-Null }
}
}
# Ensure azure-devops extension is installed
$ext = Invoke-Az extension list --query "[?name=='azure-devops']" -o tsv
if (-not $ext) {
Write-Host "Installing azure-devops extension..." -ForegroundColor Yellow
Invoke-Az extension add --name azure-devops --yes | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to install azure-devops extension. (az: $script:LastAzError)"
exit 1
}
}
# Configure az devops defaults
Invoke-Az devops configure --defaults organization=$Organization project=$Project | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to configure az devops defaults. (az: $script:LastAzError)"
exit 1
}
# --- Step 1: Get build info to determine version ---
Write-Host "Fetching build $BuildId info..." -ForegroundColor Cyan
$buildJson = Invoke-Az pipelines build show --id $BuildId --output json
if (-not $buildJson) {
Write-Error "Could not fetch build $BuildId. Are you logged in (az login)? (az: $script:LastAzError)"
exit 1
}
$build = $buildJson | ConvertFrom-Json
$versionParam = $build.templateParameters.VersionNumber
if (-not $versionParam) {
Write-Error "Could not determine version from build $BuildId"
exit 1
}
Write-Host " Version: $versionParam" -ForegroundColor DarkGray
# --- Step 2: Get artifact metadata once ---
Write-Host "Fetching artifact metadata..." -ForegroundColor Cyan
$artifactsJson = Invoke-Az pipelines runs artifact list --run-id $BuildId --output json
if (-not $artifactsJson) {
Write-Error "Could not list artifacts for build $BuildId. (az: $script:LastAzError)"
exit 1
}
$artifacts = $artifactsJson | ConvertFrom-Json
# --- Step 3: Prepare destination folder ---
$destFolder = 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
# --- 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) {
Write-Error "Failed to acquire ADO access token. Run 'az login' first. (az: $script:LastAzError)"
exit 1
}
# --- 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" }
)
# --- 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
}
$fileUrl = Get-ArtifactDownloadUrl -BaseUrl $artifact.resource.downloadUrl -SubPath "/$($t.FileName)" -Format file
Write-Host "Downloading $($t.FileName) ..." -ForegroundColor Cyan
try {
Invoke-AdoDownload -Url $fileUrl -DestPath $destPath -Token $token
}
catch {
Write-Error "Download failed for $($t.FileName): $_"
exit 1
}
$sizeMB = [math]::Round((Get-Item $destPath).Length / 1MB, 1)
Write-Host " Saved ($sizeMB MB)" -ForegroundColor Green
}
# --- Step 6b: Download symbols (one zip per arch) ---
$symbolTargets = @(
[pscustomobject]@{ Arch = "x64"; Artifact = "build-x64-Release"; SubPath = "/symbols-x64" }
[pscustomobject]@{ Arch = "arm64"; Artifact = "build-arm64-Release"; SubPath = "/symbols-arm64" }
)
foreach ($s in $symbolTargets) {
$finalZip = Join-Path $destFolder "symbols-$($s.Arch).zip"
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
continue
}
$artifact = $artifacts | Where-Object { $_.name -eq $s.Artifact }
if (-not $artifact) {
Write-Error "Artifact '$($s.Artifact)' not found in build $BuildId."
exit 1
}
# Symbols are downloaded as a folder => keep format=zip and append subPath
$symbolsUrl = Get-ArtifactDownloadUrl -BaseUrl $artifact.resource.downloadUrl -SubPath $s.SubPath -Format zip
$tmpZip = Join-Path ([System.IO.Path]::GetTempPath()) ("ptsym-$($s.Arch)-$([Guid]::NewGuid().ToString('N')).zip")
$tmpExtract = Join-Path ([System.IO.Path]::GetTempPath()) ("ptsym-$($s.Arch)-$([Guid]::NewGuid().ToString('N'))")
$stageRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("ptsym-stage-$([Guid]::NewGuid().ToString('N'))")
try {
Write-Host "Downloading symbols-$($s.Arch).zip ..." -ForegroundColor Cyan
try {
Invoke-AdoDownload -Url $symbolsUrl -DestPath $tmpZip -Token $token
}
catch {
Write-Error "Symbols download failed for $($s.Arch): $_"
exit 1
}
Write-Host " Extracting..." -ForegroundColor DarkGray
Expand-Archive -Path $tmpZip -DestinationPath $tmpExtract -Force
# Walk down while the current dir holds exactly one subfolder and no files.
$current = Get-Item $tmpExtract
while ($true) {
$children = Get-ChildItem -LiteralPath $current.FullName -Force
$subDirs = @($children | Where-Object { $_.PSIsContainer })
$files = @($children | Where-Object { -not $_.PSIsContainer })
if ($subDirs.Count -eq 1 -and $files.Count -eq 0) {
$current = $subDirs[0]
}
else {
break
}
}
# Stage to a folder named symbols-<arch> so the zip extracts to that name.
$stageInner = Join-Path $stageRoot "symbols-$($s.Arch)"
New-Item -ItemType Directory -Path $stageInner -Force | Out-Null
Get-ChildItem -LiteralPath $current.FullName -Force | ForEach-Object {
Copy-Item -LiteralPath $_.FullName -Destination $stageInner -Recurse -Force
}
Write-Host " Repacking to $finalZip ..." -ForegroundColor DarkGray
if (Test-Path $finalZip) { Remove-Item $finalZip -Force }
Compress-Archive -Path "$stageInner\*" -DestinationPath $finalZip -CompressionLevel Optimal
$sizeMB = [math]::Round((Get-Item $finalZip).Length / 1MB, 1)
Write-Host " Saved symbols-$($s.Arch).zip ($sizeMB MB)" -ForegroundColor Green
}
catch {
# Don't leave a half-built zip behind if anything in the pipeline blew up.
if (Test-Path $finalZip) { Remove-Item $finalZip -Force -ErrorAction SilentlyContinue }
throw
}
finally {
Remove-Item $tmpZip -Force -ErrorAction SilentlyContinue
Remove-Item $tmpExtract -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item $stageRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
# --- Step 7: Compute SHA256 and build markdown ---
Write-Host "`nComputing SHA256 hashes..." -ForegroundColor Cyan
$sb = [System.Text.StringBuilder]::new()
[void]$sb.AppendLine("## Installer Hashes")
[void]$sb.AppendLine("")
[void]$sb.AppendLine("| Description | Filename | sha256 hash |")
[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
}
$markdown = $sb.ToString()
$mdPath = Join-Path $destFolder "hashes.md"
Set-Content -Path $mdPath -Value $markdown -Encoding UTF8
Write-Host "`nMarkdown written to: $mdPath" -ForegroundColor Green
Write-Host "`n----- Installer Hashes -----`n" -ForegroundColor Yellow
Write-Host $markdown
Write-Host "Draft a new GitHub release at: https://github.com/$GitHubRepo/releases/new?tag=v$versionParam" -ForegroundColor Green
Related skills
FAQ
What sources can I use?
GitHub milestone PRs or specified commit ranges.
What output format?
Release notes suitable for GitHub releases and user-facing changelogs.
Are PRs categorized?
Yes. Changes are grouped by PowerToys feature area.
Is Release Note Generation safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.