mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-08-29 10:09:43 +02:00
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:
committed by
Boliang Zhang (from Dev Box)
parent
abf346a864
commit
d17d4e94e7
80
.github/agents/prepare-preview-release.agent.md
vendored
Normal file
80
.github/agents/prepare-preview-release.agent.md
vendored
Normal 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.
|
||||
51
.github/skills/release-note-generation/SKILL.md
vendored
51
.github/skills/release-note-generation/SKILL.md
vendored
@@ -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) |
|
||||
|
||||
34
.github/skills/release-note-generation/references/preview-delta-resolution.md
vendored
Normal file
34
.github/skills/release-note-generation/references/preview-delta-resolution.md
vendored
Normal 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.
|
||||
39
.github/skills/release-note-generation/references/preview-draft-safety.md
vendored
Normal file
39
.github/skills/release-note-generation/references/preview-draft-safety.md
vendored
Normal 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.
|
||||
18
.github/skills/release-note-generation/references/preview-reporting.md
vendored
Normal file
18
.github/skills/release-note-generation/references/preview-reporting.md
vendored
Normal 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.
|
||||
12
.github/skills/release-note-generation/references/scenarios/index.md
vendored
Normal file
12
.github/skills/release-note-generation/references/scenarios/index.md
vendored
Normal 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.
|
||||
136
.github/skills/release-note-generation/references/scenarios/preview-release.md
vendored
Normal file
136
.github/skills/release-note-generation/references/scenarios/preview-release.md
vendored
Normal 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).
|
||||
12
.github/skills/release-note-generation/references/scenarios/stable-release.md
vendored
Normal file
12
.github/skills/release-note-generation/references/scenarios/stable-release.md
vendored
Normal 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.
|
||||
123
.github/skills/release-note-generation/scripts/collect-pr-metadata.ps1
vendored
Normal file
123
.github/skills/release-note-generation/scripts/collect-pr-metadata.ps1
vendored
Normal 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
|
||||
}
|
||||
329
.github/skills/release-note-generation/scripts/get-preview-release-delta.ps1
vendored
Normal file
329
.github/skills/release-note-generation/scripts/get-preview-release-delta.ps1
vendored
Normal 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
|
||||
}
|
||||
165
.github/skills/release-note-generation/scripts/get-previous-published-release.ps1
vendored
Normal file
165
.github/skills/release-note-generation/scripts/get-previous-published-release.ps1
vendored
Normal 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
|
||||
433
.github/skills/release-note-generation/scripts/get-release-build-metadata.ps1
vendored
Normal file
433
.github/skills/release-note-generation/scripts/get-release-build-metadata.ps1
vendored
Normal 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
|
||||
47
.github/skills/release-note-generation/scripts/github-tag-target.ps1
vendored
Normal file
47
.github/skills/release-note-generation/scripts/github-tag-target.ps1
vendored
Normal 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'."
|
||||
}
|
||||
}
|
||||
60
.github/skills/release-note-generation/scripts/new-preview-release-manifest.ps1
vendored
Normal file
60
.github/skills/release-note-generation/scripts/new-preview-release-manifest.ps1
vendored
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
125
.github/skills/release-note-generation/scripts/preview-release-assets.ps1
vendored
Normal file
125
.github/skills/release-note-generation/scripts/preview-release-assets.ps1
vendored
Normal 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)
|
||||
}
|
||||
232
.github/skills/release-note-generation/scripts/upsert-draft-preview-release.ps1
vendored
Normal file
232
.github/skills/release-note-generation/scripts/upsert-draft-preview-release.ps1
vendored
Normal 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
|
||||
}
|
||||
234
.github/skills/release-note-generation/scripts/verify-draft-preview-release.ps1
vendored
Normal file
234
.github/skills/release-note-generation/scripts/verify-draft-preview-release.ps1
vendored
Normal 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
|
||||
}
|
||||
15
.github/skills/release-note-generation/scripts/web-response-content.ps1
vendored
Normal file
15
.github/skills/release-note-generation/scripts/web-response-content.ps1
vendored
Normal 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
|
||||
}
|
||||
650
.github/skills/release-note-generation/tests/preview-release.Tests.ps1
vendored
Normal file
650
.github/skills/release-note-generation/tests/preview-release.Tests.ps1
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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" `
|
||||
|
||||
96
.pipelines/tests/writeReleaseMetadata.Tests.ps1
Normal file
96
.pipelines/tests/writeReleaseMetadata.Tests.ps1
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
85
.pipelines/writeReleaseMetadata.ps1
Normal file
85
.pipelines/writeReleaseMetadata.ps1
Normal 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
|
||||
Reference in New Issue
Block a user