.agents/skills/release-publish/SKILL.md
Publish packages to NuGet.org and finalize releases.
⚠️ NO UNDO: This is Step 4 of 4 in the release pipeline (final step). See releasing.md for full workflow.
Pipeline: Step 1: release-branch → Step 2: release-status → Step 3: release-testing → Step 4 (this skill)
🛑 NEVER commit directly to
mainorskiasharpbranches. This is a policy violation.
| Repository | Protected Branches | Required Action |
|---|---|---|
| SkiaSharp (parent) | main | Tags/releases created from release branches, never modify main directly |
| externals/skia (submodule) | main, skiasharp | Never modify directly |
Publishing creates tags on existing release branches — it does NOT modify protected branches.
┌────────────────────────────────────────────────────────────────────┐
│ 1. Confirm Versions → Verify packages exist on preview feed │
│ 2. Publish to NuGet.org → Trigger Azure pipeline (manual) │
│ 3. Verify Published → Poll NuGet.org until indexed │
│ 4. Tag Release → Push git tag (ask_user first!) │
│ 5. Refresh Web Notes → Dispatch docs workflow (tag→stable flip)│
│ 6. Create GitHub Release→ Generate notes, set prerelease flag │
│ 7. Customer Teaser → Extract key bits from the generated log │
│ 8. Close Milestone → Close this version's milestone (all) │
└────────────────────────────────────────────────────────────────────┘
Preview vs Stable differences:
| Step | Preview | Stable |
|---|---|---|
| 1. NuGet version | X.Y.Z-preview.N.{build} | X.Y.Z (no build number) |
| 2. Pipeline checkbox | "Push Preview" | "Push Stable" |
| 4. Tag format | vX.Y.Z-preview.N.{build} | vX.Y.Z |
| 5. Website notes refresh | Dispatch (usually a no-op) | Dispatch — flips page to stable |
| 6. GitHub Release | --prerelease flag | No flag, attach samples |
| 7. Customer teaser | Breaking + What's New + Fixes (usually short) | + Dependency Updates + contributors |
| 8. Milestone | Close its milestone (X.Y.Z-preview.N) | Close its milestone (X.Y.Z) |
When identifying which version to publish, use semver ordering, not alphabetical:
3.119.2 (bare) is NEWER than 3.119.2-preview.3 — it's the stable/final releaserelease/3.119.2 and release/3.119.2-preview.3 exist, the bare version is the latestPrerequisite: release-testing must have passed. Versions should be known from testing.
The user should provide:
3.119.2-preview.2.3)3.119.2) — no build number⚠️ Stable versions never include a build number. The build number only appears in the prerelease component (e.g., 3.119.2-preview.2.3) or in the internal stable tag (e.g., 3.119.2-stable.3). It is never appended to the base version directly.
If not provided, ask for them using ask_user.
Quick verification — confirm packages exist on preview feed:
# Preview: search for the exact NuGet version
dotnet package search SkiaSharp --source "https://aka.ms/skiasharp-eap/index.json" --exact-match --prerelease --format json | jq -r '.searchResult[].packages[].version' | grep "{expected-version}"
# Stable: search for internal stable builds (NuGet version is just the base, e.g., 3.119.2)
dotnet package search SkiaSharp --source "https://aka.ms/skiasharp-eap/index.json" --exact-match --prerelease --format json | jq -r '.searchResult[].packages[].version' | grep "^{base}-stable\."
If missing, STOP and ask user to verify testing was completed.
Trigger the publish pipeline to push packages to NuGet.org.
Before triggering the publish pipeline, confirm builds completed using the release-status skill:
python3 .agents/skills/release-status/scripts/pipeline-status.py release/{version}
The SkiaSharp pipeline (ID 10789) must show ✅ — this is the pipeline that produced the
packages on the internal feed. See release-status for details.
release/{version} (the release branch)⚠️ Before approving the push step, verify BOTH:
Only approve the push step when both are correct. Wait for pipeline completion (typically 5-10 minutes after approval).
Ask user to follow these steps and wait for completion.
Use curl to verify (more reliable than dotnet package search which has version limits):
# Check if packages exist - HTTP 200 = success
curl -s -o /dev/null -w "%{http_code}" "https://api.nuget.org/v3-flatcontainer/skiasharp/{version}/skiasharp.nuspec"
curl -s -o /dev/null -w "%{http_code}" "https://api.nuget.org/v3-flatcontainer/harfbuzzsharp/{version}/harfbuzzsharp.nuspec"
If packages not yet indexed, poll until available (NuGet.org can take 5-15 minutes):
# Poll every 30 seconds, max 10 minutes
for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
skia=$(curl -s -o /dev/null -w "%{http_code}" "https://api.nuget.org/v3-flatcontainer/skiasharp/{version}/skiasharp.nuspec")
hb=$(curl -s -o /dev/null -w "%{http_code}" "https://api.nuget.org/v3-flatcontainer/harfbuzzsharp/{version}/harfbuzzsharp.nuspec")
echo "$(date +%H:%M:%S) - SkiaSharp: $skia, HarfBuzzSharp: $hb"
if [ "$skia" = "200" ] && [ "$hb" = "200" ]; then
echo "✅ Both packages available on NuGet.org!"
break
fi
sleep 30
done
Note: Use explicit list
1 2 3...instead of{1..20}brace expansion for better compatibility with async shell execution.
Or manually check: https://www.nuget.org/packages/SkiaSharp/{version}
Tag formats:
vX.Y.Z-preview.N.{build} (e.g., v3.119.2-preview.2.5)vX.Y.Z (e.g., v3.119.2)git fetch origin
git checkout release/{branch-version}
git pull
git tag {tag}
Confirm with ask_user before pushing tag (cannot be undone):
git push origin {tag}
The website release-notes and API-diff pages (documentation/docfx/releases/) are
produced by the Sync - Release Notes & API Diffs workflow. That workflow runs
daily and on pushes to main — it deliberately no longer triggers on v*
tags — so after pushing the tag in Step 4, dispatch it manually to refresh the
site immediately instead of waiting up to ~24h for the next daily run.
This matters most for stable releases: a clean vX.Y.Z tag is what flips that
version's page from "preview / unreleased" to stable, and the page cannot flip
until the workflow runs again with that tag in place — so dispatch it after tagging.
For previews it is usually a no-op (the release-branch push already refreshed the
pages), and the workflow opens no PR when nothing changed, so this step is always
safe to run.
# Always dispatch from main (it regenerates every release's pages, including the
# tag you just pushed). Do NOT dispatch from the release branch.
gh workflow run "Sync - Release Notes & API Diffs" --repo mono/SkiaSharp --ref main
# Optional: follow the run to completion.
gh run watch "$(gh run list --workflow 'Sync - Release Notes & API Diffs' --repo mono/SkiaSharp --branch main --limit 1 --json databaseId --jq '.[0].databaseId')" --repo mono/SkiaSharp
If anything changed, the workflow opens (or updates) the rolling [docs]
bot/release-notes PR with the refreshed pages — review and merge it like any
docs PR. If nothing changed, no PR is opened.
⚠️ These website release notes are separate from the GitHub Release notes created in Step 6. This step updates the docfx site; Step 6 publishes the GitHub Release. Do both.
| Release Type | Title Format | Example |
|---|---|---|
| Preview | Version X.Y.Z (Preview N) | Version 3.119.2 (Preview 2) |
| Stable | Version X.Y.Z | Version 3.119.2 |
| Hotfix Preview | Version X.Y.Z.F (Preview N) | Version 3.119.2.1 (Preview 1) |
| Hotfix Stable | Version X.Y.Z.F | Version 3.119.2.1 |
Always use --notes-start-tag to explicitly specify the previous release. The auto-selection may pick the wrong tag.
# List recent tags to find the previous release
git tag -l "v3.119*" --sort=-v:refname | head -10
| Current Release | Previous Tag (--notes-start-tag) |
|---|---|
v3.119.2-preview.2.3 | v3.119.2-preview.1.2 (previous preview) |
v3.119.2-preview.1.1 | v3.119.1 (last stable) |
v3.119.2 (stable) | v3.119.2-preview.N.X (last preview of this version) |
v3.119.2.1-preview.1.1 (hotfix) | v3.119.2 (stable being hotfixed) |
# Preview (e.g., v3.119.2-preview.2.3)
gh release create {tag} \
--title "Version {X.Y.Z} (Preview {N})" \
--generate-notes \
--notes-start-tag {previous-tag} \
--prerelease \
--verify-tag
# Stable (e.g., v3.119.2)
gh release create {tag} \
--title "Version {X.Y.Z}" \
--generate-notes \
--notes-start-tag {previous-tag} \
--verify-tag
# Upload samples for stable releases (if available)
gh release upload {tag} samples.zip
--title sets the release title (use format above)--generate-notes auto-generates release notes from PRs/commits--notes-start-tag specifies the previous release to diff from (required)--prerelease marks as prerelease (preview only)--verify-tag ensures the tag exists before creating the releaseThe generated notes are the raw input for Step 7, which extracts a short, customer-facing teaser from them and keeps this full list folded below it.
The auto-generated notes from Step 6 are a flat wall of every merged PR (CI,
version bumps, dependency refreshes, backports — 100+ lines). Maintainers told us this
is "too heavy and hard to find things." So we keep that full list (it carries the PR
numbers + author handles for free) but fold it into a <details> block and add a
short customer teaser on top with only the bits a package consumer cares about.
The teaser is generated only from the release log we just created — no website
release-notes, no documentation/docfx/ files, no git operations, no waiting.
👉 See references/github-release-teaser.md — the canonical playbook with the full classification rules, teaser template, and a worked example. Process:
Capture the generated log:
gh release view {tag} --json body -q '.body' > /tmp/skiasharp/release/generated-log.md
Build the teaser from generated-log.md following the doc's Classifying the PRs
section. In short: drop the plumbing (CI/build/test, build-tooling and version bumps,
docs/notes automation, backport, internal refactors), then classify the rest into these
sections in this order and omit any that are empty:
[Obsolete]/
deprecated APIs (incl. promoted to warning/error), changed defaults, min-version or TFM
drops.[skia-sync] engine-sync fixes).Updated <dep> to <version>. Never write "security" or name a CVE.End each bullet with by @author (#NNNN), then add a Thanks to our contributors: line
of the unique community handles. Open with one neutral subtitle line.
Assemble the final body — teaser on top, then the captured log folded below —
per the template, and write it to /tmp/skiasharp/release/release-body.md.
Update the release:
gh release edit {tag} --notes-file /tmp/skiasharp/release/release-body.md
This is the only content step for the GitHub Release. The richer, categorized website release notes are produced separately by the workflow dispatched in Step 5; the teaser links out to them but never reads or waits on them.
Required for every release — preview, rc, and stable. SkiaSharp now creates a
GitHub milestone for every version in the cadence, so each published version has its
own milestone that must be closed once that version ships (otherwise it lingers open
forever). The milestone title is the exact release version (no v prefix):
| Release type | Milestone to close |
|---|---|
| Preview | X.Y.Z-preview.N (e.g. 4.151.0-preview.1) |
| RC | X.Y.Z-rc.N (e.g. 4.151.0-rc.1) |
| Stable | X.Y.Z (e.g. 4.151.0) |
⚠️ Move still-open issues forward first. Never silently close a milestone that still has open issues. Reassign any open issues to the next appropriate milestone (the next preview/rc, or the stable milestone), or confirm with the user, before closing.
# 1. Find the milestone whose title matches the exact release version
gh api repos/:owner/:repo/milestones --jq '.[] | "\(.number): \(.title)"'
# 2. Check for still-open issues in that milestone (should be empty before closing)
gh issue list --repo mono/SkiaSharp --milestone "{version}" --state open
# 3. After moving/confirming any open issues, close this version's milestone
gh api repos/:owner/:repo/milestones/{number} -X PATCH -f state=closed
| Failure Point | Recovery |
|---|---|
| Pipeline won't start | Verify branch name, check Azure DevOps permissions |
| Build fails mid-run | Check logs, fix issue on release branch, re-run pipeline |
| Approval rejected | Re-trigger pipeline with correct settings |
| Push step fails | Check NuGet.org status, retry pipeline |
| Issue | Recovery |
|---|---|
| Indexing takes >15 min | Normal for large packages. Keep polling. |
| Package shows 404 after publish | Wait up to 30 min. NuGet CDN propagation delay. |
| Wrong version published | Cannot unpublish. Release new corrected version. |
| Issue | Recovery |
|---|---|
| Tag push rejected | Check if tag exists: git ls-remote --tags origin | grep {tag} |
| Tag already exists | Cannot delete. Must use different tag or release new version. |
| GitHub release fails | Re-run gh release create with --verify-tag |
| Release notes wrong | Edit with gh release edit {tag} --notes-file ... |
If you've partially completed and need to resume:
gh release view {tag} (release exists?), git ls-remote --tags origin (tag exists?)