Skip to content
34 changes: 27 additions & 7 deletions .github/skills/backport-changes/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>` 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 <commits>` (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 <commits>` (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 <release-branch>`).
- 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 `- #<number>` per line).
9. **Clean up labels** - remove the `needs-backport` label from each backported PR by running `pwsh scripts/Remove-BackportLabels.ps1 -PRs <numbers>`. Leave the label on PRs you intentionally did not backport.

## Backport guidelines

Expand All @@ -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.
Expand All @@ -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.
4 changes: 4 additions & 0 deletions .github/skills/backport-changes/pr-body-template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
This pull request backports the following changes:

- #1234
- #2345
50 changes: 50 additions & 0 deletions .github/skills/backport-changes/scripts/Get-BackportDiff.ps1
Original file line number Diff line number Diff line change
@@ -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 '```'
}
55 changes: 55 additions & 0 deletions .github/skills/backport-changes/scripts/New-BackportBranch.ps1
Original file line number Diff line number Diff line change
@@ -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-<release>` 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."
Original file line number Diff line number Diff line change
@@ -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."
}
}
23 changes: 1 addition & 22 deletions .github/skills/shared/Get-ReleaseBranches.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
51 changes: 51 additions & 0 deletions .github/skills/shared/GitHelpers.ps1
Original file line number Diff line number Diff line change
@@ -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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why are failures ignored here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Just an AI-ism... let me comb over the scripts more carefully and then I'll re-request review.


$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()
}
Loading