diff --git a/src/dotnet/skills/nuget-trusted-publishing/SKILL.md b/src/dotnet/skills/nuget-trusted-publishing/SKILL.md new file mode 100644 index 0000000000..ebca1abb46 --- /dev/null +++ b/src/dotnet/skills/nuget-trusted-publishing/SKILL.md @@ -0,0 +1,164 @@ +--- +name: nuget-trusted-publishing +description: > + Set up NuGet trusted publishing (OIDC) on a GitHub Actions repo — replaces long-lived API keys + with short-lived tokens. USE FOR: trusted publishing, NuGet OIDC, keyless NuGet publish, + migrate from NuGet API key, NuGet/login, secure NuGet publishing. + DO NOT USE FOR: publishing to private feeds or Azure Artifacts (OIDC is nuget.org only). + INVOKES: shell (powershell or bash), edit, create, ask_user for guided repo setup. +--- + +# NuGet Trusted Publishing Setup + +Set up [NuGet trusted publishing](https://learn.microsoft.com/en-us/nuget/nuget-org/trusted-publishing) on a GitHub Actions repo. Replaces long-lived API keys with OIDC-based short-lived tokens — no secrets to rotate or leak. + +## Prerequisites + +- **GitHub Actions** — this skill covers GitHub Actions setup specifically (trusted publishing also supports Azure DevOps, but that requires a different configuration flow) +- **nuget.org account** — the user needs access to create trusted publishing policies + +## When to Use This Skill + +Use this skill when: +- Setting up trusted publishing for a NuGet package +- Migrating from `secrets.NUGET_API_KEY` to OIDC-based publishing +- Asked about keyless or secure NuGet publishing +- Creating a new NuGet publish workflow from scratch +- Asked to "remove NuGet API key" or "use NuGet/login" +- Setting up publishing for a dotnet tool, MCP server, or template package +- Asked about `NuGet/login@v1` or `id-token: write` + +## Safety Rules + +> ⚠️ **Bail-out rule**: If any phase fails after one fix attempt on an infrastructure/auth issue, stop and ask the user. Don't loop on environment problems. + +> ⚠️ **Never delete or overwrite without confirmation**: Removing API key secrets, deleting tags/releases, removing workflow steps, or changing package IDs. NuGet package IDs are permanent — mistakes can't be undone. + +## Process + +> **Fast-path for greenfield repos**: When the user has a simple setup (one packable project, no existing publish workflow), don't gate on multi-turn assessment. Combine phases: create the workflow immediately, include nuget.org policy guidance, local pack recommendation, and filename-matching warning all in one response. The full phased process below is for complex or migration scenarios. + +### Phase 1: Assess + +Inspect the repo and report findings before making any changes. + +1. **Find and classify packable projects** — check `.csproj` files **and `Directory.Build.props`** (package metadata is often set repo-wide). Classify in this order (earlier matches win): + - `Template` → **Template** + - `McpServer` → **MCP server** (also a dotnet tool) + - `true` → **Dotnet tool** + - Class library (`IsPackable=true` or no `OutputType`) → **Library** + - `Exe` with `true` → **Application package** (not a tool, but still publishable) + - `Exe` without `PackAsTool` or `IsPackable` → Not packable by default (ask user if they intend to publish it) + +2. **Validate structure** for each project's type: + + | Type | Required | + |------|----------| + | All | `PackageId`, `Version` (in .csproj or Directory.Build.props) | + | Dotnet tool | `PackAsTool` (required); `ToolCommandName` (optional but recommended — defaults to assembly name) | + | MCP server | `PackageType=McpServer`, `.mcp/server.json` included in package | + | Template | `PackageType=Template`, `.template.config/template.json` under content dir | + +3. **Find existing publish workflows** in `.github/workflows/` — look for `dotnet nuget push`, `nuget push`, or `dotnet pack`. + +4. **Check version consistency** — for MCP servers, verify `.csproj` `` matches both `server.json` version fields (root `version` and `packages[].version`). Flag any mismatch. + +5. **Report findings** to the user: classification, missing properties, version mismatches, existing workflows. For multi-project repos, note whether one workflow or separate workflows per package are needed. Offer to fix gaps — use `ask_user` before modifying project files. + +> ❌ See [references/package-types.md](references/package-types.md) for per-type details and required properties. + +### Phase 2: Local Verification + +Pack and verify locally before touching nuget.org — publishing errors waste a permanent version number. + +> ⚠️ **Always mention this step**, even if you defer running it. Tell the user: "Before your first publish, run `dotnet pack -c Release -o ./artifacts` to verify the .nupkg is created correctly." + +1. `dotnet pack -c Release -o ./artifacts` — verify `.nupkg` is created +2. For tools/MCP servers: install from `./artifacts`, run `--help`, uninstall +3. For libraries: inspect the `.nupkg` contents (it's a zip) + +### Phase 3: nuget.org Policy + +This phase requires the user to act on nuget.org — guide them with exact values. + +1. Determine the **repo owner**, **repo name**, and the **workflow filename** that will publish. + + > ❌ The policy requires the **exact workflow filename** (e.g., `publish.yml` or `publish.yaml`) — just the filename, no path prefix. Matching is case-insensitive. Don't use the workflow `name:` field. + +2. Guide the user to create the trusted publishing policy: + > Go to [**nuget.org/account/trustedpublishing**](https://www.nuget.org/account/trustedpublishing) → **Add policy** + > + > - **Repository Owner**: `{owner}` + > - **Repository**: `{repo}` + > - **Workflow File**: `{filename}.yml` + > - **Environment**: `release` *(only if the workflow uses `environment:`; leave blank otherwise)* + + Policy ownership: the user chooses individual account or organization. Org-owned policies apply to all packages owned by that org. + + For **private repos**: policy is "temporarily active" for 7 days — becomes permanent after the first successful publish. + +3. Guide the user to create a **GitHub Environment** (recommended but optional — provides secret scoping + approval gates): + > Repo **Settings** → **Environments** → **New environment** → `release` + > + > Add environment secret: **Name** = `NUGET_USER`, **Value** = nuget.org username (NOT email) + + Optional: add **Required reviewers** for an approval gate. + +> ⚠️ Wait for the user to confirm they've created the policy before proceeding. + +### Phase 4: Workflow Setup + +Create or modify the publish workflow. **The workflow must always be created or shown in your response** — don't defer this to a later turn. + +**Greenfield**: Create `publish.yml` from the template in [references/publish-workflow.md](references/publish-workflow.md). Adapt .NET version, project path, and environment name. Ensure your output explicitly mentions `id-token: write` and `NuGet/login@v1`. + +**Migration** (existing workflow with API key): Modify in place — + +1. **Add OIDC permission and environment** to the publishing job: + ```yaml + jobs: + publish: + environment: release + permissions: + id-token: write # Required — without this, NuGet/login fails with 403 + contents: read # Explicit — setting permissions overrides defaults + ``` + +2. **Add the NuGet login step** before push: + ```yaml + - name: NuGet login (OIDC) + id: login + uses: NuGet/login@v1 + with: + user: ${{ secrets.NUGET_USER }} # nuget.org profile name, NOT email + ``` + +3. **Replace the API key** in the push step: + ```yaml + --api-key ${{ steps.login.outputs.NUGET_API_KEY }} --skip-duplicate + ``` + +4. **Verify**: Ask the user to trigger a publish and confirm the package appears on nuget.org. + +> ❌ **Don't delete the old API key secret** until trusted publishing is verified. Removing it is a one-way door — wait for confirmation. + +## Troubleshooting + +| Problem | Cause | Fix | +|---------|-------|-----| +| `NuGet/login` 403 | Missing `id-token: write` | Add to job permissions | +| "no matching policy" | Workflow filename mismatch | Verify exact filename on nuget.org | +| Push unauthorized | Package not owned by policy account | Check policy owner on nuget.org | +| Token expired | Login step >1hr before push | Move `NuGet/login` closer to push | +| "temporarily active" policy | Private repo, first publish pending | Publish within 7 days | +| `already_exists` on push | Re-running same version | Add `--skip-duplicate` | +| GitHub Release 422 | Duplicate release for tag | Delete conflicting release (confirm first) | +| Re-run uses wrong YAML | `gh run rerun` replays original commit's YAML | Delete obstacle, re-run — never re-tag | + +> ⚠️ If any blocker persists after one fix attempt, **stop and ask the user**. + +## References + +- **Package type details**: [references/package-types.md](references/package-types.md) — detection logic, required properties, minimal .csproj examples +- **Publish workflow template**: [references/publish-workflow.md](references/publish-workflow.md) — complete tag-triggered workflow ready to adapt +- **Microsoft docs**: [NuGet Trusted Publishing](https://learn.microsoft.com/en-us/nuget/nuget-org/trusted-publishing) diff --git a/src/dotnet/skills/nuget-trusted-publishing/references/package-types.md b/src/dotnet/skills/nuget-trusted-publishing/references/package-types.md new file mode 100644 index 0000000000..4c21bd6ef3 --- /dev/null +++ b/src/dotnet/skills/nuget-trusted-publishing/references/package-types.md @@ -0,0 +1,256 @@ +# NuGet Package Type Reference + +Structural requirements for each NuGet package type. The agent uses this to validate a repo's packaging setup before configuring trusted publishing. + +## Detection Logic + +Inspect `.csproj` files (and `Directory.Build.props` if present) for these MSBuild properties: + +``` +1. Has Template? → Template package +2. Has McpServer? → MCP server (also a dotnet tool) +3. Has true? → Dotnet tool +4. Has true or no OutputType? → NuGet library +5. Has Exe + true? → Application package +6. Has Exe without PackAsTool or IsPackable? → Not packable by default (ask user) +``` + +Check in order — MCP servers have `PackAsTool` too, so `PackageType` must be checked first. + +## NuGet Library + +The most common case. A class library consumed via `PackageReference`. + +### Required Properties + +| Property | Example | Notes | +|----------|---------|-------| +| `PackageId` | `Contoso.Utilities` | Defaults to `AssemblyName` if omitted | +| `Version` | `0.1.0` | Start with 0.x for initial development | + +### Recommended Properties + +| Property | Purpose | +|----------|---------| +| `Authors` | Package author(s) | +| `Description` | Shown on nuget.org | +| `PackageTags` | Discoverability | +| `PackageReadmeFile` | README displayed on nuget.org | +| `PackageLicenseExpression` | SPDX license identifier | +| `RepositoryUrl` | Link back to source | +| `PublishRepositoryUrl` | Enables source-link integration | + +### Including README in Package + +`PackageReadmeFile` alone isn't enough — you must also include the file in the package: + +```xml + + README.md + + + + + + +``` + +### Minimal .csproj + +```xml + + + net9.0 + Contoso.Utilities + 0.1.0 + Contoso + Utility library for Contoso apps + README.md + + + + + +``` + +### Pack Command + +```bash +dotnet pack -c Release +``` + +## Dotnet Tool + +A console app distributed as a global or local tool via `dotnet tool install`. + +### Required Properties + +| Property | Example | Notes | +|----------|---------|-------| +| `OutputType` | `Exe` | Must be an executable | +| `PackAsTool` | `true` | Marks this as a tool package | +| `PackageId` | `contoso-cli` | Tool package identifier | +| `Version` | `0.1.0` | Package version (or set in Directory.Build.props) | + +### Recommended Properties + +| Property | Example | Notes | +|----------|---------|-------| +| `ToolCommandName` | `contoso` | Command users type; defaults to assembly name | +| `PackageOutputPath` | `./nupkg` | Where .nupkg is written | +| `PackageReadmeFile` | `README.md` | Shown on nuget.org | + +### Minimal .csproj + +```xml + + + Exe + net9.0 + true + contoso + contoso-cli + README.md + + + + + +``` + +### Pack Command + +```bash +dotnet pack -c Release +``` + +## MCP Server + +A dotnet tool that implements the Model Context Protocol. Distributed the same way as a dotnet tool but with additional metadata for MCP client discovery. + +### Naming Convention + +Follow the established pattern for MCP server packages: +- **PackageId**: `{github-username}.{domain}.mcp` (e.g., `lewing.helix.mcp`) +- **server.json `name`**: `io.github.{username}/{packageid}` (e.g., `io.github.lewing/lewing.helix.mcp`) + +### Required Properties + +Everything from Dotnet Tool, plus: + +| Property | Example | Notes | +|----------|---------|-------| +| `PackageType` | `McpServer` | NuGet recognizes this as an MCP server | + +### Recommended Properties + +| Property | Example | Notes | +|----------|---------|-------| +| `McpServerJsonTemplateFile` | `.mcp/server.json` | MCP client discovery metadata | + +### Required Files + +| File | Purpose | +|------|---------| +| `.mcp/server.json` | MCP server descriptor for client discovery | + +The `.mcp/server.json` must be included in the package: + +```xml + + + +``` + +### Minimal server.json + +```json +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", + "name": "io.github.contoso/contoso.services.mcp", + "description": "MCP server for Contoso services", + "version": "0.1.0", + "packages": [ + { + "registryType": "nuget", + "registryBaseUrl": "https://api.nuget.org", + "identifier": "contoso.services.mcp", + "version": "0.1.0", + "transport": { "type": "stdio" } + } + ] +} +``` + +> ⚠️ **Version sync**: The `version` fields in `server.json` MUST match the `` in your `.csproj`. Update both when bumping versions. + +> ⚠️ **nuget.org MCP Server tab**: nuget.org auto-generates MCP install config from your `server.json`. If your tool requires a subcommand (e.g., `my-tool mcp`), the generated config may omit it. Ensure your tool defaults to MCP server mode when invoked with no arguments, or uses a `--yes` flag for non-interactive acceptance. + +### Minimal .csproj + +```xml + + + Exe + net9.0 + true + McpServer + contoso.services.mcp + contoso-mcp + .mcp/server.json + README.md + + + + + + +``` + +## Template Package + +A package containing `dotnet new` templates. + +### Required Properties + +| Property | Example | Notes | +|----------|---------|-------| +| `PackageType` | `Template` | NuGet recognizes this as a template package | +| `PackageId` | `Contoso.Templates` | Template package identifier | + +### Required Files + +| File | Purpose | +|------|---------| +| `content/*/.template.config/template.json` | Template definition — one per template | + +The `template.json` must include at minimum: `identity`, `name`, `shortName`, `tags.type` (`item` or `project`). + +### Minimal .csproj + +```xml + + + Template + Contoso.Templates + 0.1.0 + Contoso project templates + + + true + false + content + true + + + + + +``` + +## Common Gotchas + +- **`IsPackable` defaults**: Class libraries default to `true`, console apps to `false`. Console apps can still be published as NuGet packages by setting `true` — they just won't be installable via `dotnet tool install` unless `PackAsTool` is also set. +- **`Directory.Build.props`**: Package metadata may be set at the repo root — always check there too. +- **Multi-project repos**: A repo may contain multiple packable projects of different types. Each needs its own trusted publishing workflow or a matrix build. +- **`GeneratePackageOnBuild`**: If `true`, `dotnet build` also produces the `.nupkg`. The workflow should use `dotnet pack` explicitly for clarity. diff --git a/src/dotnet/skills/nuget-trusted-publishing/references/publish-workflow.md b/src/dotnet/skills/nuget-trusted-publishing/references/publish-workflow.md new file mode 100644 index 0000000000..d74311c9fb --- /dev/null +++ b/src/dotnet/skills/nuget-trusted-publishing/references/publish-workflow.md @@ -0,0 +1,102 @@ +# Publish Workflow Template + +Complete tag-triggered GitHub Actions workflow for publishing NuGet packages with trusted publishing. Copy and adapt to your repo. + +## Template + +```yaml +name: Publish to NuGet + +on: + push: + tags: + - 'v*' # Triggers on version tags: v1.0.0, v1.2.3-preview.1, etc. + +jobs: + publish: + runs-on: ubuntu-latest + environment: release # Uses release environment for secret scoping + protection rules + permissions: + id-token: write # Required for OIDC token (NuGet trusted publishing) + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' # Adjust to your target framework + + - name: Extract version from tag + id: version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + + - name: Validate version matches project + run: | + PROJECT_VERSION=$(sed -n 's:.*\(.*\).*:\1:p' path/to/YourProject.csproj) + if [ "$PROJECT_VERSION" != "${{ steps.version.outputs.VERSION }}" ]; then + echo "::error::Tag version (${{ steps.version.outputs.VERSION }}) doesn't match project version ($PROJECT_VERSION)" + exit 1 + fi + + - name: Pack + run: dotnet pack path/to/YourProject.csproj -c Release -o ./artifacts + + - name: NuGet login (OIDC) + id: login + uses: NuGet/login@v1 + with: + user: ${{ secrets.NUGET_USER }} # nuget.org profile name (NOT email) + + - name: Push to NuGet + run: dotnet nuget push ./artifacts/*.nupkg --api-key ${{ steps.login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate +``` + +## Customization Points + +| Item | What to change | +|------|---------------| +| `dotnet-version` | Match your `TargetFramework` | +| `path/to/YourProject.csproj` | Path to your packable project | +| Version extraction `sed` | Adjust for `Directory.Build.props` or .NET 10 file-based apps (`#:property Version=`) | +| `--skip-duplicate` | Keeps push idempotent — safe for re-runs and matrix builds | + +## Release Process + +Once the workflow is committed, the publish process is: + +```bash +# 1. Bump version in .csproj (and server.json for MCP servers) +# 2. Commit +git add -A && git commit -m "Bump version to 0.1.0" +# 3. Tag and push +git tag v0.1.0 +git push origin main --tags +# 4. Workflow runs automatically, publishes to nuget.org +``` + +## Optional: GitHub Release Step + +Add after the push step if you want GitHub Releases with the `.nupkg` attached. Note: this requires changing `contents: read` to `contents: write` in the job permissions. + +```yaml + - name: Create GitHub Release + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 + with: + files: ./artifacts/*.nupkg + generate_release_notes: true +``` + +> ⚠️ **Consider omitting this step entirely.** Creating GitHub Releases separately (manually or via `gh release create` in a different workflow) avoids 422 `already_exists` conflicts and keeps the publish workflow focused on NuGet. If any release step fails, it blocks NuGet publishing too. + +> ⚠️ **Don't use `ncipollo/release-action` AND `gh release create` for the same tag** — this causes HTTP 422 `already_exists` errors. + +> ⚠️ **`gh run rerun` replays the original YAML** from the tag commit, not from `main`. If the workflow fails due to a release conflict, delete the conflicting release and re-run — don't delete the tag and re-tag (NuGet package IDs are permanent). + +## CI vs Publish Separation + +Keep your CI workflow (build + test on PR/push) separate from the publish workflow (tag-triggered). This gives you: +- CI runs on every PR without publishing +- Publish only runs on deliberate version tags +- Different permission scopes (CI doesn't need `id-token: write`) diff --git a/src/dotnet/tests/nuget-trusted-publishing/eval.yaml b/src/dotnet/tests/nuget-trusted-publishing/eval.yaml new file mode 100644 index 0000000000..8f44dd9d7c --- /dev/null +++ b/src/dotnet/tests/nuget-trusted-publishing/eval.yaml @@ -0,0 +1,54 @@ +scenarios: + - name: "Set up trusted publishing for a new NuGet library" + prompt: | + I have a .NET class library at src/MyLib/MyLib.csproj that I want to publish + to nuget.org using trusted publishing (OIDC) instead of an API key. + The repo is hosted on GitHub at myorg/mylib. Help me set this up. + assertions: + - type: "output_contains" + value: "NuGet/login" + - type: "output_contains" + value: "id-token" + - type: "output_matches" + pattern: "(trusted.?publishing|OIDC)" + rubric: + - "Guides the user to create a trusted publishing policy on nuget.org" + - "Mentions the workflow filename must match the nuget.org policy exactly" + - "Includes id-token: write in the workflow permissions" + - "Recommends local pack verification before publishing" + timeout: 120 + - name: "Set up NuGet publishing without mentioning trusted publishing" + prompt: | + I have a .NET library at src/MyLib/MyLib.csproj and I want to publish it + to nuget.org securely from GitHub Actions without storing API keys. + The repo is at myorg/mylib. How do I set this up? + assertions: + - type: "output_contains" + value: "NuGet/login" + - type: "output_contains" + value: "id-token" + - type: "output_matches" + pattern: "(trusted.?publishing|OIDC)" + rubric: + - "Identifies that OIDC/trusted publishing is the right approach for keyless publishing" + - "Creates or shows a workflow with NuGet/login and id-token: write" + - "Guides the user to configure a trusted publishing policy on nuget.org" + - "Recommends local pack verification before publishing" + timeout: 120 + - name: "Migrate existing workflow from API key to trusted publishing" + prompt: | + My current publish workflow uses secrets.NUGET_API_KEY to push packages. + I want to switch to NuGet trusted publishing (OIDC). What do I need to change? + assertions: + - type: "output_contains" + value: "NuGet/login" + - type: "output_contains" + value: "id-token" + - type: "output_matches" + pattern: "(api.?key|API.?KEY|NUGET_API_KEY)" + rubric: + - "Explains how to replace the API key with NuGet/login OIDC step" + - "Mentions adding id-token: write permission to the job" + - "Advises keeping the old API key secret until trusted publishing is verified" + - "Mentions creating a trusted publishing policy on nuget.org" + timeout: 120