Publish SkiaSharp packages and finalize the release. Use when user says "publish X", "finalize X", "tag X", or "finish release X". This is the FINAL step - after release-testing passes. Publishes to NuGet.org, creates tag, GitHub release, and closes milestone. Triggers: "publish the release", "push to nuget", "create github release", "tag the release", "close the milestone", "annotate release notes", "testing passed what's next", "finalize 3.119.2", "release is ready".
Publish packages to NuGet.org and finalize releases.
⚠️ NO UNDO: This is step 3 of 3. See releasing.md for full workflow.
🛑 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. Create GitHub Release→ Generate notes, set prerelease flag │
│ 6. Annotate Notes → Add platform/contributor emojis │
│ 7. Close Milestone → Stable releases only │
└────────────────────────────────────────────────────────────────────┘
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. GitHub Release | --prerelease flag | No flag, attach samples |
| 7. Milestone | Skip | Close milestone |
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.
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}
| 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 releaseAfter creating the release, annotate each PR line with platform and community emojis.
👉 See references/release-notes.md for:
Quick summary:
gh release view {tag} --json body -q '.body' > /tmp/skiasharp/release/release-body.mdgh release edit {tag} --notes-file /tmp/skiasharp/release/release-body.mdSkip for preview releases.
gh api repos/:owner/:repo/milestones --jq '.[] | "\(.number): \(.title)"'
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?)