Skip to content

Rehearse the release weekly from the release pipeline itself - #783

Merged
Nikola Metulev (nmetulev) merged 20 commits into
mainfrom
azchohfi-weekly-release-dry-run
Sep 1, 2026
Merged

Nikola Metulev (nmetulev) merged 20 commits into
mainfrom
azchohfi-weekly-release-dry-run

Conversation

@azchohfi

@azchohfi Alexandre Zollinger Chohfi (azchohfi) commented Aug 25, 2026 •

Copy link
Copy Markdown
Collaborator

Why

ES/1ES policy changes keep surfacing mid-release — the most expensive possible moment. The release stalls, someone edits the pipeline under time pressure, and we ship late or ship twice.

This makes release.yml rehearse a release every Monday from main, so a policy or credential failure shows up days before anyone cuts rel/v*.

How

There is no separate dry-run pipeline. The real release pipeline runs weekly with only the final publishing actions omitted — same 1ES Official template, same stages, same jobs, same tasks, same service connections. A lookalike pipeline could only ever approximate those.

The mode is derived from the branch, never from a parameter. This is the load-bearing detail: Azure DevOps compiles both scheduled and branch-triggered runs with the default values of parameters:. A dryRun parameter therefore cannot distinguish a Monday rehearsal from a real release — defaulting it false makes the weekly run publish for real; defaulting it true makes every real release silently ship nothing.

Every gate is a positive test for rel/v*, so an unexpected branch fails closed. Grep refs/heads/rel/v.

What the rehearsal skips

Action Gate
ESRP code signing DoEsrp ANDed with the branch
GitHub release compile-time ${{ if }}
Symbol publication stage omitted
npm publish stage omitted
nuget.org push stage omitted
WinGet fork sync + --submit WINGET_SUBMIT env, 'false' unless rel/v*
MS Learn push + PR MSLEARN_PUBLISH env, 'false' unless rel/v*

Everything else runs for real: the full stable build with telemetry unstubbed, all tests, packaging, release-notes generation, the real Release_GitHub job up to the upload, WinGet manifest generation (installers downloaded and hashed, stopping at --submit), and MS Learn porting/validation/commit (stopping at the push).

Review findings addressed

A full multi-dimension review ran on this branch. The headline catch came from the different-model cross-check:

Critical — main moved under this branch. #795 reverted the NuGet-publish split, putting a Release_NuGet + 1ES.PublishNuget@1 stage back into release.yml gated only on DoEsrp. Since DoEsrp defaults to true and scheduled runs compile with defaults, merging main would have made the weekly rehearsal push to nuget.org. Now branch-gated like every other publishing stage.

Also fixed:

  • Credentialed steps restricted to main with a positive condition, rather than "any branch that isn't rel/v*". Production tokens were reachable from any branch someone could queue.
  • WingetPkgsFork validated via env instead of ADO macro expansion — it was interpolated unquoted into an inline PowerShell body, where ; is a statement separator.
  • WINGET_SUBMIT / MSLEARN_PUBLISH defined explicitly as 'false' in a rehearsal. Azure injects pipeline variables into the task environment, so an absent variable could be inherited rather than meaning "do not publish".
  • Credential check made advisory (continueOnError). It was a hard gate early in Build, so a dead Models token would abort before the WinGet and MS Learn rehearsals — the stages this exists to run.
  • WinGet rehearses against the release list, not /releases/latest, which excludes prereleases. Every release here ships as a prerelease, so it was validating an ever-staler version.
  • Portable winappcli-<arch>.zip names verified in Release_GitHub, where they're created. The upload glob is winappcli-*.zip and their URLs are hardcoded in the WinGet submission, so a rename would have uploaded fine and 404'd later.
  • Asset-name preflight promoted to every build, not just rehearsals — a real release could previously publish versioned names and break the WinGet URLs silently, exactly Fix: Wingetcreate installer count mismatch in release pipeline #568.

Plus a cut list, removing ~90 lines the review found weren't earning their keep: a dead -SkipZip branch (the only caller always passed it), -ExpectedArchitectures, -TreatWarningsAsErrors, the rate-limit check, the redundant product-repo check, and a release-notes gate that structurally could not fire.

Separately, generate-release-notes.ps1 no longer echoes its output to stdout — that text is built from PR titles and model output, and Azure Pipelines executes any ##vso[...] line, so a crafted PR title could set pipeline variables.

AGENTS.md: the corp-machine test guidance was wrong

Unrelated to the pipeline but found while validating this branch. AGENTS.md called the local NuGet test failures "a known limitation, not a flaky test" — so I initially wrote off 69 failing tests as environmental.

They weren't. With the right configuration the full suite passes: 4621 tests, 0 failures.

The reason it was easy to get wrong is that two different mechanisms resolve packages:

  • NugetService reads the WINAPP_NUGET_* variables AGENTS.md already documented → fixed 62.
  • The end-to-end tests shell out to dotnet add package, which goes through the NuGet client, reads nuget.config, and ignores those variables entirely → the last 7. Those need VSS_NUGET_EXTERNAL_FEED_ENDPOINTS; VSS_NUGET_ACCESSTOKEN alone returns 401 on the service index.

A repo-local nuget.config can't help, because those tests run in %TEMP% outside the repo. AGENTS.md now documents a session-scoped fix (redirect APPDATA at a temp dir holding .pipelines/release-nuget.config) and explicitly says not to register a global package source.

Validation

  • Structural audit of the parsed YAML confirms every publishing action is gated — zero ungated.
  • The security reviewer's injection payload is refuted by the new guard.
  • 4621 unit tests, 0 failures. 74 Pester tests pass.
  • New tests are deliberately offline-only: build-cli.ps1 runs scripts/tests during a real release build, so a test reaching api.github.com would let a GitHub outage block a release.

Setup required before this does anything

No new pipeline is needed — the schedule runs on the existing WinDevCLI - Release definition, which already has its variable groups and connections authorized. Two things to check in the ADO UI, both documented in .pipelines/README.md:

  1. "Override the YAML schedule" must be off, or the weekly trigger will not fire.
  2. Any Branch control check on the shared service connections must allow both refs/heads/rel/v* and refs/heads/main — a main-only filter would block real releases.

Known limitations (documented, not papered over)

Signing is never exercised in a rehearsal, so an ESRP-side break still surfaces only during a real release. Service-connection checks prove authorization, not that the stored secret works. The rehearsal validates the publish steps' preconditions, not the final API calls.

Phase 2 — a scheduled agent prompt that reads each weekly run and posts a summary to the team — is intentionally a follow-up, to be written once there are real runs to summarize.

ES/1ES policy changes keep surfacing mid-release, which is the most expensive
time to find them. This adds a scheduled pipeline that rehearses a release every
Monday so a policy or credential problem shows up days before anyone cuts rel/v*.

.pipelines/dryrun.yml runs on the 1ES *Unofficial* template since it produces
nothing shippable. It does the full stable build with telemetry unstubbed, all
test suites, MSIX/NuGet/npm packaging, and the release-only steps CI never
touches: release-notes generation (as a hard gate rather than continueOnError),
MS Learn doc porting run from a copy of release.yml's mslearn-source file list,
and release asset renaming plus zip archiving verified against the names the
WinGet manifest URLs hardcode.

It signs nothing, creates no GitHub release, and performs no fork operations.
Credentials are validated read-only by scripts/check-release-credentials.ps1:
PAT scopes and expiry, fork push permission, GitHub Models reachability, ADO
service connection readiness, and the symbol publishing federated identity.

Also:

- Extract the shared agent setup into templates/build-env.yaml so an ES policy
  change to feeds or auth is one edit instead of three.
- Stop echoing generated release notes to stdout. They are built from PR titles
  and model output, and Azure Pipelines executes any stdout line starting with
  ##vso[...] as a logging command.

The scripts/tests suite runs during the real release build, so the new tests are
deliberately offline-only.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dbafe00e-a7b1-48f9-8065-453bd752ac9b
Copilot AI balanced review requested due to automatic review settings August 25, 2026 18:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a weekly, non-publishing rehearsal of the release pipeline.

Changes:

  • Adds release-fidelity build and credential validation.
  • Extracts shared pipeline environment setup.
  • Adds asset-staging checks and release-script tests.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
.pipelines/dryrun.yml Defines the weekly rehearsal pipeline.
.pipelines/templates/build-env.yaml Centralizes agent and feed setup.
.pipelines/ci.yml Uses shared environment setup.
.pipelines/release.yml Uses shared setup and documents staging parity.
.pipelines/README.md Documents operation, security, and setup.
AGENTS.md Adds pipeline maintenance guidance.
scripts/check-release-credentials.ps1 Validates release credentials and connections.
scripts/stage-release-assets.ps1 Renames and verifies release assets.
scripts/generate-release-notes.ps1 Prevents untrusted notes reaching pipeline stdout.
scripts/tests/check-release-credentials.Tests.ps1 Tests offline credential-check behavior.
scripts/tests/stage-release-assets.Tests.ps1 Tests asset naming and verification.
Suppressed comments (1)

scripts/check-release-credentials.ps1:294

  • The repository probe has the same false-failure path: any non-404 response, including a timeout or GitHub 5xx, is marked FAIL even though it does not prove the fork or token is unusable. Preserve 404 as the definitive failure for these public repositories and classify other request failures as WARN.
    if (-not $result.Ok) {
        if ($result.StatusCode -eq 404) {
            Add-Result -Status 'FAIL' -Check $Label -Detail "'$Repo' was not found, or the token cannot see it. If the fork was deleted, recreate it - wingetcreate and the docs PR both depend on it."
        }
        else {
            Add-Result -Status 'FAIL' -Check $Label -Detail "GET /repos/$Repo failed with status $($result.StatusCode)."
        }

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread scripts/check-release-credentials.ps1
Comment thread .pipelines/dryrun.yml Outdated
Comment thread .pipelines/dryrun.yml Outdated
Comment thread scripts/check-release-credentials.ps1 Outdated
@github-actions

github-actions Bot commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

Build Metrics Report

Binary Sizes

Artifact Baseline Current Delta
CLI (ARM64) 38.63 MB 38.63 MB ✅ 0.0 KB (0.00%)
CLI (x64) 38.75 MB 38.75 MB ✅ 0.0 KB (0.00%)
MSIX (ARM64) 16.03 MB 16.03 MB 📈 +0.1 KB (+0.00%)
MSIX (x64) 17.02 MB 17.02 MB 📈 +0.0 KB (+0.00%)
NPM Package 33.43 MB 33.43 MB 📉 -0.4 KB (-0.00%)
NuGet Package 33.47 MB 33.47 MB 📈 +0.1 KB (+0.00%)

Test Results

✅ 4619 passed, 5 skipped out of 4624 tests in 643.0s (+0.7s vs. baseline)

Test Coverage

✅ 89.1% line coverage, 82.5% branch coverage · ✅ no change vs. baseline

CLI Startup Time

51ms median (x64, winapp --version) · ✅ -6ms vs. baseline

Try This Build

Installs the MSIX for your architecture, replacing any previously installed build. Needs the GitHub CLI — the command offers to install it and sign you in if it is missing.

& ([scriptblock]::Create((irm https://raw.githubusercontent.com/microsoft/winappCli/main/scripts/winapp-pr.ps1))) 783
Switching between builds often?

Put the tool on your PATH once:

& ([scriptblock]::Create((irm https://raw.githubusercontent.com/microsoft/winappCli/main/scripts/winapp-pr.ps1))) -AddToPath

Then this build is just:

winapp-pr 783

Run winapp-pr with no arguments to pick from a list of open PRs.


Updated 2026-09-01 21:33:17 UTC · commit 43cdf55 · workflow run

1ES release jobs cannot check out the repo, so release.yml inlined its asset renames and the
dry run kept a second copy in a script. That is exactly the drift the dry run exists to prevent.

YAML `- template:` references are expanded at compile time, so a release job CAN share logic -
just as YAML rather than as a .ps1. Move the renames into templates/release-assets.yaml and have
both consume it, then keep verification (which only the dry run needs, and which has a checkout)
in verify-release-assets.ps1.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dbafe00e-a7b1-48f9-8065-453bd752ac9b
Copilot AI added 5 commits August 26, 2026 16:07
The dry run was a separate pipeline that reproduced the release's shape. A lookalike can only
approximate the thing it is rehearsing, and it needed its own ADO registration, variable groups
and service-connection authorization.

Instead, run the REAL release every Monday from main with only the final publishing actions
omitted: same 1ES Official template, same stages, same jobs, same tasks, same connections. This
also closes the SDL gap the separate pipeline had, since it stays on Official rather than
dropping to Unofficial.

The mode is derived from the branch, never from a parameter. Azure DevOps compiles scheduled AND
branch-triggered runs with parameter DEFAULTS, so a dryRun parameter cannot tell a Monday
rehearsal from a real release - defaulting it false would publish weekly, defaulting it true
would make every release ship nothing. Each gate is a positive test for rel/v*, so an unexpected
branch fails closed.

Six things are skipped, and nothing else: ESRP signing (DoEsrp ANDed with the branch, since it
defaults to true and would otherwise sign), the GitHub release, the symbol stage, the npm stage,
the WinGet fork sync and --submit, and the MS Learn push and PR. Where the step is a script
rather than a task the gate is a YAML-conditional env var the script branches on, so an absent
variable means "do not publish". nuget.org needs no gate: release-nuget.yml only triggers on
rel/v*.

WinGet rehearses against the last published release, because no release exists yet for the
in-flight version and wingetcreate downloads the installers to hash them.

Also promote the asset-name check from rehearsal-only to a preflight on every build. A real
release could previously publish versioned asset names and silently break the WinGet URLs, which
is what #568 was.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dbafe00e-a7b1-48f9-8065-453bd752ac9b
main reverted the NuGet-publish split (#795), which reintroduced a Release_NuGet stage into
release.yml gated only on DoEsrp. DoEsrp defaults to true and a scheduled run compiles with
parameter defaults, so merging main would have made the weekly rehearsal push to nuget.org.
Gate it on the release branch like every other publishing stage.

Also from the review:

- Restrict the credentialed rehearsal steps to `main` with a positive condition, instead of
  "any branch that is not rel/v*". Production tokens were reachable from any branch someone
  could queue; now an unrelated branch gets the build and asset preflight and nothing else.
- Validate WingetPkgsFork through env rather than ADO macro expansion. It was interpolated
  unquoted into an inline PowerShell body, where a ';' in the value is a statement separator.
  The MS Learn step already did this correctly; the WinGet step now matches it.
- Define WINGET_SUBMIT and MSLEARN_PUBLISH explicitly as 'false' in a rehearsal. Azure injects
  pipeline variables into the task environment, so an absent variable could be inherited rather
  than simply meaning "do not publish".
- Make the credential check advisory. It ran as a hard gate early in Build, so a dead Models
  token would abort before the WinGet and MS Learn rehearsals - the stages this exists to run,
  and where releases have actually broken.
- Rehearse WinGet against the release list rather than /releases/latest, which excludes
  prereleases. Every release here is published as a prerelease, so the rehearsal was validating
  an ever-staler version and would have 404'd once no stable release remained.
- Verify the portable winappcli-<arch>.zip names in Release_GitHub, where they are created. The
  Build preflight cannot see them, the upload glob is winappcli-*.zip, and their URLs are
  hardcoded in the WinGet submission - so a rename would have uploaded fine and 404'd later.

Cut what the review found unearning: the dead -SkipZip branch (the only caller always passed
it), -ExpectedArchitectures, -TreatWarningsAsErrors, the rate-limit check, the product-repo
reachability check, and a release-notes gate that could not fire. Fixed stale dryrun.yml
references left behind when that pipeline was removed.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dbafe00e-a7b1-48f9-8065-453bd752ac9b
scripts/build-cli.ps1 regenerates src/winapp-npm/src/winapp-commands.ts, which bumped its
recorded schema version from 0.6.1 to 0.6.3 when I ran the build to validate this branch. That
staleness predates this PR and has nothing to do with the release pipeline, so restore main's
version rather than carrying an unrelated change.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dbafe00e-a7b1-48f9-8065-453bd752ac9b
@azchohfi Alexandre Zollinger Chohfi (azchohfi) changed the title Add a weekly release dry run pipeline Rehearse the release weekly from the release pipeline itself Aug 27, 2026
Copilot AI added 6 commits August 26, 2026 21:00
AGENTS.md called the bulk NuGet test failures "a known limitation, not a flaky test", which is
wrong and led me to accept 69 failures as environmental. Setting the feed variables it already
documents takes the unit suite from 69 failures to 7 - measured, not estimated.

Document the remaining 7 precisely: they shell out to `dotnet add package Microsoft.WindowsAppSDK`,
which goes through the NuGet client and reads nuget.config rather than WINAPP_NUGET_*, so the feed
variables cannot help them. pde-oss_Internal does carry that package, and CI resolves this by
copying release-nuget.config over the user-level NuGet.Config - noted, along with the fact that
VSS_NUGET_ACCESSTOKEN alone does not authenticate the service index.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dbafe00e-a7b1-48f9-8065-453bd752ac9b
The previous guidance was wrong in both directions. It called the NuGet test failures "a known
limitation, not a flaky test", and it documented only half the setup - so following it left 7
failures that still looked environmental.

They were not. With the complete configuration the entire unit suite passes locally on a corp
machine: 4621 tests, 0 failures.

The missing half is that two different mechanisms resolve packages. NugetService reads the
WINAPP_NUGET_* variables, which the file already documented. The end-to-end tests instead shell
out to `dotnet add package Microsoft.WindowsAppSDK`, which goes through the NuGet client, reads
nuget.config, and ignores those variables entirely - so it needs the internal feed registered as
a source plus VSS_NUGET_EXTERNAL_FEED_ENDPOINTS for the Azure Artifacts credential provider.
VSS_NUGET_ACCESSTOKEN alone returns 401 on the service index, which is the trap.

Registering the source stores no password; the credential provider supplies it at runtime.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dbafe00e-a7b1-48f9-8065-453bd752ac9b
The previous revision told you to register the internal feed as a global package source. That is a
persistent change to a shared dev box to make a test run, and it duplicates a feed URL the repo
already stores in .pipelines/release-nuget.config.

Redirect APPDATA at a throwaway directory holding that same config instead. NuGet resolves its
user-level config from %APPDATA%\NuGet\NuGet.Config, so this swaps the feed list for the session
and leaves the real one untouched. Verified: dotnet add package Microsoft.WindowsAppSDK resolves
from the internal feed with no global source registered.

A repo-local nuget.config cannot work here, which is the non-obvious part: the end-to-end tests
run in %TEMP%, outside the repo, so NuGet never walks up to it.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dbafe00e-a7b1-48f9-8065-453bd752ac9b
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dbafe00e-a7b1-48f9-8065-453bd752ac9b
A rehearsal that publishes nothing looks like it belongs on Unofficial, and that was the original
design, so this will read like a mistake to the next person. It is not: Unofficial enforces a
weaker SDL subset, and TSA, CodeQL TSA integration, SBOM and signing validation - Official-only -
are exactly the policies that keep changing under us. Rehearsing on Unofficial would be blind to
the failures the rehearsal exists to catch.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dbafe00e-a7b1-48f9-8065-453bd752ac9b
Re-review caught that the previous round's cut removed -SkipZip from verify-release-assets.ps1 but
left it in the release.yml call site. The script is an advanced function, so an unknown parameter
is a terminating binding error - the ungated Build preflight would have failed EVERY run,
including a real rel/v* release, before any artifact shipped. Reproduced locally (exit 1), fixed
by deleting the argument.

Two safety mechanisms were also documented as working when they were not:

- `Generate Release Notes` was made a hard gate on the rehearsal branch to surface a dead token.
  It cannot: generate-release-notes.ps1 catches its own API and model failures and falls back to a
  git-log changelog, exiting 0 either way. Restored unconditional continueOnError and corrected
  the claim; the Models probe in check-release-credentials.ps1 is what actually detects this.
- The credential check ran with continueOnError, so a FAIL only rendered the run orange with no
  watcher - and phase 2, the thing that would read it, is explicitly unbuilt. Moved it into a
  trailing Verify_Release_Credentials stage that depends on the rehearsal stages, so a dead
  credential turns the run red without costing the WinGet and MS Learn coverage.

Also gate Release_WinGet and Release_MSLearn on rel/v* or main. Both hand GITHUB_TOKEN_2 to
scripts read off the source branch, and the WINGET_SUBMIT / MSLEARN_PUBLISH gates stop the
publishing but not the token exposure - so an unrelated branch someone queued could read the PAT.
The README claimed this was already handled; now it is, and the docs say plainly that it is
defense in depth rather than a boundary.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dbafe00e-a7b1-48f9-8065-453bd752ac9b
…r pass

Copilot review flagged that GitHub Models was fully retired on 2026-07-30; a live probe confirms
410 Gone for every token. The check I added would therefore have failed the weekly rehearsal every
single week, forever - and I had just moved it into a stage without continueOnError, so the Monday
build would have been permanently red for a reason nobody could fix. That is precisely the alert
fatigue this pipeline exists to avoid.

Remove the probe and its parameters, and document that AI release notes are permanently dead and
every release now ships the git-log fallback.

Also stop classifying transport failures as credential failures. Any non-401 response from
GET /user was reported FAIL, so a DNS blip, proxy hiccup or GitHub 5xx would send someone
rotating a live PAT. Those are now WARN and say plainly that the result is inconclusive; 403 is
called out separately as likely SSO de-authorization.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dbafe00e-a7b1-48f9-8065-453bd752ac9b

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.

Suppressed comments (1)

scripts/check-release-credentials.ps1:192

  • A transient GitHub failure is classified as a definitive credential failure. For example, a simulated network outage produces status 0, adds [FAIL] GitHub token, and makes the final rehearsal stage fail, although the script’s contract says an inconclusive probe is WARN. Reserve FAIL for the explicit 401 branch and warn for other statuses so GitHub outages do not create false credential alarms.
            $hasRepoWrite = ($scopeList -contains 'repo') -or ($scopeList -contains 'public_repo')
            if (-not $hasRepoWrite) {
                Add-Result -Status 'FAIL' -Check 'GitHub token scopes' -Detail "Neither 'repo' nor 'public_repo' is granted. WinGet submission and the MS Learn docs PR both push branches and will fail."

Comment thread .pipelines/release.yml
Comment thread scripts/check-release-credentials.ps1
Comment thread .pipelines/README.md Outdated
Comment thread .pipelines/README.md Outdated
…vs-failed split

Copilot review caught that #795's revert dropped useDotNetTask: false when folding Release_NuGet
back into release.yml. The deleted pipeline carried it deliberately - DotNetCoreCLI cannot consume
the encrypted API key from the NuGet-WinAppCLI service connection - so the nuget.org push would
fail to authenticate on the next real release. Restored along with the retry it also lost.

Two fixes from the previous round turned out not to have applied; the string replaces silently
missed. Test-RepoAccess still reported every non-404 as FAIL, and the on-demand docs still claimed
any non-rel/v* branch gives a rehearsal, which stopped being true when Release_WinGet,
Release_MSLearn and Verify_Release_Credentials were restricted to main. Both are now correct, and
the docs say plainly that a green feature-branch run is not evidence the release path works.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dbafe00e-a7b1-48f9-8065-453bd752ac9b

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

scripts/verify-release-assets.ps1:82

  • This does not verify the NuGet asset's expected name; any unversioned package passes. For example, a staging set containing only Wrong.Package.nupkg prints “All expected release assets are present,” so a PackageId/naming regression reaches the release stages undetected. Require exactly Microsoft.Windows.SDK.BuildTools.WinApp.nupkg and update the fixture to use that real contract name.
$nuget = @(Get-AssetNames -Sub 'nuget-packages' -Filter '*.nupkg')
if (-not $nuget) {
    $errors.Add('No .nupkg assets were produced.')
}

.pipelines/release.yml:813

  • The rehearsal skips the fork sync above but still clones the fork, so it can validate a stale docs tree. A real release syncs first and then runs against current upstream content; clone $docsUpstream on the rehearsal path so Monday's run exercises the same input without mutating the fork.
                # Clone the fork
                Write-Host "Cloning docs repo fork..."
                $docsDir = "$tempDir\windows-dev-docs-pr"
                & $ghExe repo clone $docsFork $docsDir -- --depth 1

Comment thread .pipelines/release.yml Outdated
Comment thread scripts/check-release-credentials.ps1 Outdated
…tion failure

GitHub returns 403 for both authorization failures and rate limiting, so the status alone is not a
verdict. Throttling the pipeline PAT would have been reported as a dead credential and sent
someone rotating a working token.

Distinguish them by the x-ratelimit-remaining and retry-after headers. That needed two supporting
fixes: Invoke-GitHubApi discarded headers on the error path, so the rate-limit signal never
reached the caller, and Get-HeaderValue assumed the IDictionary shape from a successful response -
an exception's HttpResponseHeaders has no .Keys and would have thrown under StrictMode.

Also drop a stale comment pointing at the GitHub Models probe removed in the previous commit.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dbafe00e-a7b1-48f9-8065-453bd752ac9b

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.

Comment thread .pipelines/release.yml Outdated
An authenticated releases list includes drafts, and the rehearsal took the first entry. A
maintainer creating a draft after the last prerelease would make the Monday run pick that draft
tag, whose assets are not downloadable, so wingetcreate would fail for a reason unrelated to
anything the rehearsal is meant to detect.

Filter out drafts and take the first real release, keeping prereleases eligible - this pipeline
publishes every release as a prerelease, which is why /releases/latest cannot be used here.
Verified against the live API: returns v0.6.1, the actual newest release, where /releases/latest
returns the two-versions-stale v0.6.0.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dbafe00e-a7b1-48f9-8065-453bd752ac9b

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

.pipelines/release.yml:806

  • The rehearsal skips syncing the fork but then clones that fork, so it validates whatever stale main the fork last had rather than this week's upstream docs. For example, an upstream change after the last real release is absent from Monday's rehearsal, yet the next real release syncs it and can fail. Keep the production sync, but clone $docsUpstream on the non-publishing path so the rehearsal uses current docs without mutating the fork.
                # Sync the fork with upstream. Skipped in a rehearsal: it mutates the fork, and
                # nothing gets pushed anyway. Push access is asserted by
                # check-release-credentials.ps1 in Build, which checks the permission rather than
                # just reachability.
                if ($env:MSLEARN_PUBLISH -eq 'true') {

@nmetulev
Nikola Metulev (nmetulev) merged commit 8816efd into main Sep 1, 2026
30 checks passed
@nmetulev
Nikola Metulev (nmetulev) deleted the azchohfi-weekly-release-dry-run branch September 1, 2026 21:48
Alexandre Zollinger Chohfi (azchohfi) added a commit that referenced this pull request Sep 15, 2026
… cannot see them (#850)

## What happened

The first real weekly rehearsal ([build
20260914.1](https://microsoft.visualstudio.com/pde-oss/_build/results?buildId=157509154))
failed with four verdicts like:

```
[FAIL] Service connection 'github-service-connection': Not found in project 'pde-oss'.
```

**All four connections exist and are ready.** Verified against the same
API with an interactive identity:

| Connection | count | type | isReady |
|---|---|---|---|
| `github-service-connection` | 1 | github | True |
| `WinSDKCLI - ESRP Code Signing MI` | 1 | azurerm | True |
| `WinAppCLI - Symbol Publishing` | 1 | azurerm | True |
| `NuGet-WinAppCLI` | 1 | externalnugetfeed | True |

## Root cause

Azure DevOps does **not** return 401/403 when the caller lacks endpoint
read permission. It returns **HTTP 200 with an empty list** —
byte-identical to "this connection does not exist". The build service
identity has no such permission by default.

`check-release-credentials.ps1` treated an empty result as definitive
absence, so it accused four healthy connections and failed the run.

This is the same definitive-vs-inconclusive inversion review already
caught twice on the `/user` and repository probes in #783. I didn't
apply the lesson to this third call site.

## Fix

Probe visibility before trusting an empty result:

- **Can enumerate any endpoint** → visibility works, so a missing name
is meaningful and still `FAIL`s.
- **Can enumerate none** → one inconclusive `WARN` naming the permission
to grant, instead of one false `FAIL` per connection.

Verified both paths against the live API:

```
CASE A (can enumerate):
  [PASS] github-service-connection ... [PASS] NuGet-WinAppCLI ... [PASS] WinAppCLI - Symbol Publishing
  [FAIL] this-does-not-exist: Not found in project 'pde-oss'.     ← still has teeth

CASE B (blind identity):
  [WARN] Service connections: The build identity cannot enumerate service endpoints ...
         Grant the build service 'Read' on the service connections to enable this check.
                                                                   ← one WARN, not four FAILs
```

## Also

- **Regression test** using an unroutable address (`127.0.0.1:1`), so it
stays offline — `build-cli.ps1` runs `scripts/tests` during a real
release build, and a test that reached the network could let an outage
block a release.
- **`.pipelines/README.md`** documents the required grant (connection →
Security → add `<project> Build Service` as Reader) and explains why the
empty-list behavior makes it non-obvious.

86 Pester tests pass; YAML validates.

## Worth noting: the rest of the rehearsal worked

Every other stage succeeded in rehearsal mode — `Build`, `Create GitHub
Release`, `Create WinGet Release`, `Update MS Learn Docs` — and nothing
was published. The design held up; only this one check was wrong.

It also surfaced a **genuine** finding that needs separate action:
`GITHUB_TOKEN_2` expires **2026-09-17**, and the run flagged it with 3
days' notice. That is exactly what the check was built for.

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dbafe00e-a7b1-48f9-8065-453bd752ac9b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants