diff --git a/.github/skills/backport-changes/SKILL.md b/.github/skills/backport-changes/SKILL.md index d01c4bf31c..5f3a4e02b4 100644 --- a/.github/skills/backport-changes/SKILL.md +++ b/.github/skills/backport-changes/SKILL.md @@ -9,14 +9,19 @@ disable-model-invocation: true ## Workflow -1. **Determine the release branch**: Run `pwsh ../shared/Get-ReleaseBranches.ps1` to find the latest public release branch. - - The most recently created public release branch corresponds to the current release. - - Do not assume `main` is the release branch. +1. **Set up the working branch**: Run `pwsh scripts/New-BackportBranch.ps1` to automatically create a new branch based on the latest release branch. + - Pass `-ReleaseBranch release/` to target a specific release branch instead of the latest. 2. **Get candidates for backport**: Run `pwsh scripts/Get-BackportPRs.ps1` to find PRs to backport. -3. **Analyze PRs** - classify each PR using the backport guidelines below and present the analysis table to the user -4. **Cherry-pick** - confirm the plan with the user, create a working branch based off of the public release branch, then run `git cherry-pick ` (in the order they were merged). +3. **Analyze PRs** - classify each PR using the backport guidelines below and present the analysis table to the user. +4. **Cherry-pick** - confirm the backport plan with the user, then run `git cherry-pick ` (in the order they were merged). - If any templates or manifests changed, regenerate Dockerfiles and READMEs. Confirm that the diff looks correct. -5. **Resolve conflicts** - follow the conflict resolution table below; stop and consult the user for anything not covered +5. **Resolve conflicts** - follow the conflict resolution table below; stop and consult the user for anything not covered. +6. **Verify nothing was missed** - from your working branch with all cherry-picks committed and a clean working tree, run `pwsh scripts/Get-BackportDiff.ps1` to diff `HEAD` against `nightly`. Confirm every reported difference is an expected divergence (see below). Investigate anything that is not. +7. **Confirm changes** - confirm the full set of changes with the user. If the user requests additional changes, get confirmation again before moving to the next step. +8. **Open a draft PR** - push the working branch and open a **draft** PR targeting the public release branch (`gh pr create --draft --base `). + - Title: `Backport changes from nightly to $targetReleaseBranch` (e.g. `Backport changes from nightly to release/2026-05B`). + - Body: use [pr-body-template.md](pr-body-template.md), replacing the example PR numbers with the list of PRs you backported (one `- #` per line). +9. **Clean up labels** - remove the `needs-backport` label from each backported PR by running `pwsh scripts/Remove-BackportLabels.ps1 -PRs `. Leave the label on PRs you intentionally did not backport. ## Backport guidelines @@ -26,10 +31,11 @@ disable-model-invocation: true - Dockerfile/template changes - structural changes to how images are built - Image component updates - MinGit, PowerShell, and other tools - Infrastructure and tooling changes - build scripts, CI/CD updates - - Automated `eng/common` updates - standard engineering infrastructure + - Automated `eng/docker-tools` updates - standard engineering infrastructure - Do not backport: - Version-only updates for daily/preview builds (no Dockerfile changes) - Changes already on the release branch + - Merge commits (e.g. "Merge main to nightly") - that content reaches the release branch via the main cut, not the backport - Experimental or incomplete features - Requires extra consideration: - Daily builds of .NET or appliance images - only backport if they include Dockerfile changes beyond simple version updates. @@ -55,3 +61,17 @@ After analyzing PRs, present results in a table: | `manifest.json` | Take changes from nightly, ensure `latest` tags are on the correct (non-preview) versions, then regenerate Dockerfiles and READMEs. | | `manifest.versions.json` | Keep the latest/most up-to-date versions. | | Other files | Stop and consult the user. | + +## Expected divergences from nightly + +When verifying (step 6), the following differences between the release branch and `nightly` are expected and do **not** indicate a missed backport. Note that a single in-progress feature on `nightly` usually shows up as a *cluster* of related files (Dockerfiles, templates, tag metadata, version entries, and test baselines) — recognize the feature, don't classify file-by-file. + +- **Preview version drift** - `nightly` carries newer daily/preview build numbers that are intentionally not backported. Shows up across `src/*` Dockerfiles, `README*.md`, `eng/mcr-tags-metadata-templates/*`, and version entries in `manifest.versions.json`. +- **Appliance image versions** (aspire-dashboard, monitor, yarp) - updated separately during release staging, not via backport. New appliances still in preview (e.g. yarp) may be absent from the release branch entirely. +- **New images or distros still in development on `nightly`** - e.g. support for a new pre-release Linux distro. These appear as a coupled set: new `src/*` Dockerfiles, structural `eng/dockerfile-templates/*` changes, new `eng/mcr-tags-metadata-templates/*` tag groups, `*|repo` entries in `manifest.versions.json`, and added/removed `VerifyInternalDockerfilesOutput` baselines. Expected only while the feature is not yet shipping in this release — otherwise investigate as a missed backport (see below). +- **Internal Dockerfile test baselines** - `tests/.../Baselines/GeneratedArtifactTests/VerifyInternalDockerfilesOutput/*` churn (added/removed/modified) tracks the image set above and the internal build flavor; expected alongside the image and template differences. +- **Out-of-release-scope content** - files that are never part of a servicing backport, such as `samples/*` (not generated, not part of the released image set). Differences here are expected. This is **not** a catch-all for "anything only on `nightly`" — nightly content that belongs in the release (new images ready to ship, component updates, tooling/infra, structural template changes) is precisely what a backport must carry, so treat it as a potential miss unless it falls under one of the categories above. +- **`manifest.versions.json` `branch` field** - `main` on the release branch vs `nightly`. +- **`manifest.json` repo remapping** - the release branch uses public repo names while `nightly` uses nightly ones (e.g. `dotnet/nightly/*` → `dotnet/*`, `core-nightly` → `core`, including the `syndicated*Repo` variables). + +Anything outside these categories should be investigated as a potential missed backport. Structural `eng/dockerfile-templates/*` changes are only expected when they are tied to a feature that is intentionally not shipping in this release; an isolated template change with no accompanying in-development feature normally **should** be backported. diff --git a/.github/skills/backport-changes/pr-body-template.md b/.github/skills/backport-changes/pr-body-template.md new file mode 100644 index 0000000000..37554b490f --- /dev/null +++ b/.github/skills/backport-changes/pr-body-template.md @@ -0,0 +1,4 @@ +This pull request backports the following changes: + +- #1234 +- #2345 diff --git a/.github/skills/backport-changes/scripts/Get-BackportDiff.ps1 b/.github/skills/backport-changes/scripts/Get-BackportDiff.ps1 new file mode 100644 index 0000000000..61823bb0a5 --- /dev/null +++ b/.github/skills/backport-changes/scripts/Get-BackportDiff.ps1 @@ -0,0 +1,50 @@ +#!/usr/bin/env pwsh + +# Shows the file-level diff between HEAD and the nightly branch on the public +# (GitHub) remote. Run this from your backport working branch (with cherry-picks +# applied and committed) to verify the backport: every reported difference should +# be an expected divergence (see SKILL.md). Investigate anything that is not. +# The working tree must be clean. + +. "$PSScriptRoot/../../shared/GitHelpers.ps1" + +# Detect the public (GitHub) remote so we can fetch and diff against its nightly. +$gitHubRemote = Get-PublicRemoteName + +# Require a clean working tree so that all cherry-picks and regenerated files are +# committed. This lets us diff HEAD (including new files) rather than the working +# tree, which would miss untracked files. +$status = git status --porcelain +if (-not [string]::IsNullOrWhiteSpace($status)) { + throw "Working tree is not clean. Commit or stash your changes before verifying the backport, then re-run this script." +} + +Write-Host "Fetching latest from '$gitHubRemote'..." +git fetch $gitHubRemote 2>&1 | Out-Null + +$nightlyRef = "$gitHubRemote/nightly" +if (-not (git rev-parse --verify --quiet "$nightlyRef")) { + throw "Branch '$nightlyRef' was not found on remote '$gitHubRemote'." +} + +Write-Host "" +Write-Host "# Backport verification diff" +Write-Host "" +Write-Host "Comparing HEAD against nightly on the public remote." +Write-Host "" +Write-Host "- Nightly: ``$nightlyRef``" +Write-Host "" +Write-Host "Every difference below should be an expected divergence (see SKILL.md)." +Write-Host "Investigate anything that is not." +Write-Host "" +Write-Host "## Changed files (HEAD vs nightly)" +Write-Host "" + +$nameStatus = git diff --name-status "$nightlyRef" HEAD +if ([string]::IsNullOrWhiteSpace($nameStatus)) { + Write-Host "_No differences._" +} else { + Write-Host '```' + Write-Host ($nameStatus -join "`n") + Write-Host '```' +} diff --git a/.github/skills/backport-changes/scripts/New-BackportBranch.ps1 b/.github/skills/backport-changes/scripts/New-BackportBranch.ps1 new file mode 100644 index 0000000000..7ba105bc3e --- /dev/null +++ b/.github/skills/backport-changes/scripts/New-BackportBranch.ps1 @@ -0,0 +1,55 @@ +#!/usr/bin/env pwsh + +# Determines the latest public release branch and creates a working branch for +# backporting based on it. Combines steps 1 and 2 of the backport workflow: +# detect the public remote, fetch it, find the latest release branch, and create +# a `backport-` branch off its up-to-date tip. + +[CmdletBinding()] +param( + # Optional release branch override (e.g. "release/2026-05B"). Defaults to the + # most recently created public release branch. + [string] $ReleaseBranch +) + +. "$PSScriptRoot/../../shared/GitHelpers.ps1" + +# Require a clean working tree so uncommitted changes are not carried onto the +# new branch. +$status = git status --porcelain +if (-not [string]::IsNullOrWhiteSpace($status)) { + throw "Working tree is not clean. Commit or stash your changes before creating the backport branch." +} + +if ([string]::IsNullOrWhiteSpace($ReleaseBranch)) { + # Get-LatestPublicReleaseBranch resolves the public remote, fetches it, and + # returns a remote-qualified ref (e.g. "upstream/release/2026-06B"). + $startPoint = Get-LatestPublicReleaseBranch + Write-Host "Latest public release branch: $startPoint" +} else { + $remote = Get-PublicRemoteName + Write-Host "Fetching latest from '$remote'..." + git fetch $remote 2>&1 | Out-Null + $startPoint = "$remote/$ReleaseBranch" +} + +if (-not (git rev-parse --verify --quiet "$startPoint")) { + throw "Start point '$startPoint' was not found on the public remote." +} + +$releaseName = $startPoint -replace '^.*release/', '' +# Include a timestamp so re-running the skill always produces a distinct branch name. +$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$workingBranch = "backport-$releaseName-$timestamp" + +if (git rev-parse --verify --quiet "refs/heads/$workingBranch") { + throw "Branch '$workingBranch' already exists. Delete it or check it out before re-running." +} + +# Use --no-track so the working branch does not adopt the public release branch +# as its upstream. +git switch --no-track -c $workingBranch $startPoint + +Write-Host "" +Write-Host "Created working branch '$workingBranch' based on '$startPoint'." +Write-Host "Next: get backport candidates with scripts/Get-BackportPRs.ps1." diff --git a/.github/skills/backport-changes/scripts/Remove-BackportLabels.ps1 b/.github/skills/backport-changes/scripts/Remove-BackportLabels.ps1 new file mode 100644 index 0000000000..174c78926c --- /dev/null +++ b/.github/skills/backport-changes/scripts/Remove-BackportLabels.ps1 @@ -0,0 +1,20 @@ +#!/usr/bin/env pwsh + +# Removes the 'needs-backport' label from the given pull requests in +# dotnet/dotnet-docker. Run this after successfully backporting changes to clear +# the label from PRs that have been handled. + +param( + [Parameter(Mandatory = $true)] + [int[]] $PRs +) + +$label = "needs-backport" + +foreach ($pr in $PRs) { + Write-Host "Removing '$label' from #$pr..." + gh pr edit $pr --repo dotnet/dotnet-docker --remove-label $label + if ($LASTEXITCODE -ne 0) { + Write-Warning "Failed to remove '$label' from #$pr." + } +} diff --git a/.github/skills/shared/Get-ReleaseBranches.ps1 b/.github/skills/shared/Get-ReleaseBranches.ps1 index 8545b40371..c394087084 100755 --- a/.github/skills/shared/Get-ReleaseBranches.ps1 +++ b/.github/skills/shared/Get-ReleaseBranches.ps1 @@ -4,28 +4,7 @@ # by fetching from GitHub (public) and Azure DevOps (internal) and listing by # branch creation date. -function Get-RemoteName { - param( - [Parameter(Mandatory = $true)] - [string] $UrlPattern - ) - - $matchingRemotes = @(git remote | Where-Object { - $remoteName = $_ - $remoteUrl = git remote get-url $remoteName 2>$null - $remoteUrl -match $UrlPattern - }) - - if ($matchingRemotes.Count -eq 0) { - throw "Unable to find a remote with a URL matching '$UrlPattern'." - } - - if ($matchingRemotes.Count -gt 1) { - throw "Found multiple remotes with URLs matching '$UrlPattern': $($matchingRemotes -join ', ')." - } - - return $matchingRemotes[0] -} +. "$PSScriptRoot/GitHelpers.ps1" # Show basic documentation Write-Host "# Release branches" diff --git a/.github/skills/shared/GitHelpers.ps1 b/.github/skills/shared/GitHelpers.ps1 new file mode 100644 index 0000000000..9b52a1816c --- /dev/null +++ b/.github/skills/shared/GitHelpers.ps1 @@ -0,0 +1,51 @@ +#!/usr/bin/env pwsh + +# Shared git helpers for the release/backport skills. Dot-source this file: +# . "$PSScriptRoot/GitHelpers.ps1" + +# Finds the single git remote whose URL matches the given pattern. +function Get-RemoteName { + param( + [Parameter(Mandatory = $true)] + [string] $UrlPattern + ) + + $matchingRemotes = @(git remote | Where-Object { + $remoteName = $_ + $remoteUrl = git remote get-url $remoteName 2>$null + $remoteUrl -match $UrlPattern + }) + + if ($matchingRemotes.Count -eq 0) { + throw "Unable to find a remote with a URL matching '$UrlPattern'." + } + + if ($matchingRemotes.Count -gt 1) { + throw "Found multiple remotes with URLs matching '$UrlPattern': $($matchingRemotes -join ', ')." + } + + return $matchingRemotes[0] +} + +# Finds the public (GitHub dotnet) remote. +function Get-PublicRemoteName { + return Get-RemoteName -UrlPattern 'github\.com[:/]dotnet/' +} + +# Fetches the public remote and returns the most recently created public release +# branch as a remote-qualified ref (e.g. "upstream/release/2026-06B"). The remote +# prefix is kept intentionally so callers use the remote-tracking branch rather +# than a possibly-stale local branch. +function Get-LatestPublicReleaseBranch { + $remote = Get-PublicRemoteName + git fetch $remote 2>&1 | Out-Null + + $branch = git branch -r --list "$remote/release/*" --sort=-creatordate ` + | Select-Object -First 1 + + if ([string]::IsNullOrWhiteSpace($branch)) { + throw "No release branches found on remote '$remote'." + } + + return $branch.Trim() +}