Publish .NET packages

View .md

kanject packages takes your NuGet packages from a commit to nuget.org or your private feed without the usual accidents. A public package can't depend on a private one, changed code can't ship under a version that's already published, testers install a real release candidate first, and nothing is published until a person approves it.

You'll learn
  • Add a check that stops a public package from depending on a private one
  • Set up a release that builds once, lets testers install a candidate, and waits for approval
  • Ship a change without editing a version by hand
  • Release one library without re-releasing the whole repository
  • Recover when a release fails partway

How a release flows

Every release runs as two jobs with an approval between them. The build job holds no release credentials and can only write release candidates. The publish job runs after someone approves, and ships the exact files the build made.

UNTRUSTED TRUSTED Build job verify · build · test · pack no release credentials Publish job check set · push · journal pinned CLI · release credentials approval packed files cross unchanged push rc.N install + build -rc feed testers install push push release feed approved only nuget.org public only UNTRUSTED TRUSTED Build job verify · build · test · pack no release credentials push rc.N install + build -rc feed testers install approval packed files cross unchanged Publish job check set · push · journal pinned CLI · release credentials push push release feed approved only nuget.org public only
Only the publish job can write to the release feed or nuget.org, and only after approval. Each run also writes three records: the release plan and preflight verdicts from the build, and the publication journal from the publish. The journal is how a publish that stops partway resumes.

Start with a check

Pin the CLI in the repository, describe which packages are public, and run the check. Use init --split when the repository has public and private packages, or init --all-private when nothing is public. plan --write lists the public packages in packaging/public-packages.txt, so a change to what is public shows up in code review.

bash
# Pin the CLI in the repositorydotnet new tool-manifestdotnet tool install Kanject.Cli​# Describe the repository: public and private packages, or private onlydotnet kanject packages init --split# or: dotnet kanject packages init --all-private​# List the public packages for review, then run the checkdotnet kanject packages plan --writedotnet kanject packages verify --advisories

verify fails when:

  • a public package depends on a private package from your repository
  • a packable project is not clearly marked public or private
  • a package requires a newer version of a sibling than the one being built
  • a public package is missing required metadata, such as a license
  • the project graph can't be read, or the policy has a setting it doesn't recognise — it never skips a check

Add it to every build so the mistake fails the pull request instead of the release. kanject packages ci prints the same two lines for CodeBuild or GitHub Actions; it publishes nothing.

yaml
- run: dotnet tool restore- run: dotnet kanject packages verify --advisories

Set up the release

This setup runs the two jobs on GitHub Actions with AWS CodeArtifact feeds. On CodeBuild or another CI, keep the same split: two jobs with separate roles and an approval between them. A feed that authenticates with a username and token instead of AWS roles needs a smaller policy, shown after step 2.

1. Create the feeds. The release repository holds approved releases only; the -rc repository holds candidates for testers. If any package is public, create a nuget.org API key scoped to your package IDs.

bash
# One domain, two repositories: approved releases and rc candidatesaws codeartifact create-domain --domain acmeaws codeartifact create-repository --domain acme --repository acme-packagesaws codeartifact create-repository --domain acme --repository acme-packages-rc

Give each job its own AWS role. Both need codeartifact:GetAuthorizationToken, codeartifact:GetRepositoryEndpoint, and sts:GetServiceBearerToken, plus:

  • Build: codeartifact:ReadFromRepository on both repositories, and codeartifact:PublishPackageVersion on the -rc repository only.
  • Publish: codeartifact:ReadFromRepository and codeartifact:PublishPackageVersion on the release repository.

2. Complete the policy. packages init wrote the public/private rules and set firstPartyPackagePrefixes to the prefix your package IDs share; add the prefixes of first-party packages you consume from your other repositories. Then add the feeds and release profiles to packaging/kanject.packages.json. The policy names environment variables, never values: REPOSITORY points at the release repository, and the candidate profile swaps in CANDIDATE_REPOSITORY. An all-private repository leaves out the public feed and the API key.

json
{  "firstPartyPackagePrefixes": [ "Acme." ],  "feeds": {    "public": {      "kind": "nuget",      "source": "https://api.nuget.org/v3/index.json",      "apiKeyEnv": "NUGET_ORG_API_KEY"    },    "private": {      "kind": "codeartifact",      "domainEnv": "DOMAIN",      "domainOwnerEnv": "DOMAIN_OWNER",      "repositoryEnv": "REPOSITORY",      "regionEnv": "AWS_REGION"    }  },  "releaseProfiles": {    "release-evidence": {      "mode": "evidence",      "outputs": { "plan": "release-plan.json", "preflight": "preflight-verdicts.json" }    },    "candidate": {      "mode": "candidate",      "feedOverrides": { "private": { "repositoryEnv": "CANDIDATE_REPOSITORY" } },      "outputs": { "plan": "release-plan-rc.json" }    },    "approved-release": {      "mode": "release",      "expectedPackageIdsFile": "/tmp/expected-package-ids",      "outputs": {        "plan": "trusted-release-plan.json",        "journal": "release-journal.json",        "preflight": "publish-preflight-verdicts.json"      }    }  }}
json
{  "feeds": {    "private": {      "kind": "nuget",      "source": "https://nuget.pkg.github.com/acme/index.json",      "usernameEnv": "FEED_USER",      "passwordEnv": "FEED_TOKEN",      "apiKeyEnv": "FEED_TOKEN"    }  }}

3. Create the approval gate. In the repository's Settings → Environments, add an environment named release with required reviewers and a deployment-branch rule limiting it to main. For private repositories, environment protection rules depend on your GitHub plan. Then add:

  • Repository variable BUILD_ROLE_ARN — the build role, trusted for GitHub OIDC.
  • release environment variable PUBLISH_ROLE_ARN — the publish role, trusted for GitHub OIDC.
  • release environment variable PUBLISHER_CLI_VERSION — the exact released Kanject.Cli version the publish job runs.
  • release environment variable EXPECTED_PACKAGES — every package ID the release ships, space-separated. Publishing refuses a set that adds, drops, or duplicates one, so adding a package is a reviewed change. packages plan lists them.
  • release environment secret NUGET_ORG_API_KEY — the nuget.org push key, for repositories with public packages.

4. Add the workflow. Save it as .github/workflows/release.yml. Every run stops for approval before anything reaches the release repository or nuget.org.

yaml
name: releaseon:  workflow_dispatch:​permissions:  contents: read  id-token: write​env:  AWS_REGION: eu-west-1  DOMAIN: acme  DOMAIN_OWNER: "111122223333"  REPOSITORY: acme-packages  CANDIDATE_REPOSITORY: acme-packages-rc​jobs:  build:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v4      - uses: actions/setup-dotnet@v4        with:          dotnet-version: 8.0.x      - uses: aws-actions/configure-aws-credentials@v4        with:          role-to-assume: ${{ vars.BUILD_ROLE_ARN }}          aws-region: ${{ env.AWS_REGION }}      - run: dotnet tool restore      - run: dotnet kanject packages verify --advisories      - run: dotnet build -c Release      - run: dotnet test -c Release --no-build      - run: dotnet pack -c Release --no-build -o packed      - name: Release evidence (pushes nothing)        run: dotnet kanject packages publish --profile release-evidence --packed-dir packed      - name: Stage candidates        run: |          RC_SUFFIX="$(REPOSITORY="$CANDIDATE_REPOSITORY" dotnet kanject packages next-rc)"          dotnet kanject packages candidate materialize --stable-dir packed --candidate-dir packed-rc --suffix "$RC_SUFFIX"          dotnet kanject packages publish --profile candidate --packed-dir packed-rc --ci      - name: Smoke the candidates from the candidate feed        run: |          RELEASE_V3="$(aws codeartifact get-repository-endpoint --domain "$DOMAIN" --domain-owner "$DOMAIN_OWNER" \            --repository "$REPOSITORY" --format nuget --query repositoryEndpoint --output text)v3/index.json"          REPOSITORY="$CANDIDATE_REPOSITORY" dotnet kanject packages smoke feed \            --plan release-plan-rc.json --dep-source "$RELEASE_V3" --build      - uses: actions/upload-artifact@v4        with:          name: release          path: |            packed/*.nupkg            release-plan.json            preflight-verdicts.json            release-plan-rc.json​  publish:    needs: build    runs-on: ubuntu-latest    environment: release    steps:      - uses: actions/checkout@v4      - uses: actions/download-artifact@v4        with:          name: release          path: release      - uses: actions/setup-dotnet@v4        with:          dotnet-version: 8.0.x      - uses: aws-actions/configure-aws-credentials@v4        with:          role-to-assume: ${{ vars.PUBLISH_ROLE_ARN }}          aws-region: ${{ env.AWS_REGION }}      - name: Install the pinned publisher        run: |          dotnet tool install -g Kanject.Cli --version "${{ vars.PUBLISHER_CLI_VERSION }}"          echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH"      - name: Publish the approved packages        env:          NUGET_ORG_API_KEY: ${{ secrets.NUGET_ORG_API_KEY }}          EXPECTED_PACKAGES: ${{ vars.EXPECTED_PACKAGES }}        run: |          printf '%s\n' $EXPECTED_PACKAGES > /tmp/expected-package-ids          PUBLISH_PUBLIC=true kanject packages publish --profile approved-release --packed-dir release/packed --ci      - uses: actions/upload-artifact@v4        if: always()        with:          name: release-journal          path: |            release-journal.json            trusted-release-plan.json            publish-preflight-verdicts.json

Guided release selection

In a terminal, packages release offers all packages or a selected package scope, an increment at or above declared intent, and a review before creating or pushing a release. intent add can prompt for missing part, note and package associations. packages publish can help select packed output and a configured profile; public-feed arming and explicit live-publish approval still apply.

aws pipeline promote can select from the latest fetched release tips when no commit/source is supplied. Rehearsal and exact-commit checks still gate the push. packages release status --select and aws pipeline status --select open the inspection picker; without it, status reports HEAD or the explicit commit. Fetch first to refresh choices. Redirected IO and CI disable prompts, so automation must supply required inputs.

Ship a change, day to day

Once the policy and the release host exist, every change to a library follows the same loop, whether it is a one-line fix or a new package. Nothing in it edits a version by hand.

1. Use the pinned CLI. dotnet tool restore installs the version in .config/dotnet-tools.json, which is the version CI runs. Bump the pin deliberately rather than installing a newer CLI on the side.

2. Branch and make the change. Leave every version and pin alone: a published version can never change, and bump apply writes the new versions for you. On the hosted pipeline, a change that is more than a patch is declared beside the code, in a small file committed with it; a bug fix needs none, because no declaration means patch.

bash
# More than a patch? Say so beside the code and commit it with the changedotnet kanject packages intent add --part minor --note "Adds IAuthProvider" --package Acme.Auth# wrote packaging/intent/2026-10-09-adds-iauthprovider.json

3. Run the gates before you push. The same commands CI runs, plus an optional, quick preview of which packages the change reaches.

bash
dotnet tool restoredotnet build -c Releasedotnet test -c Release --no-builddotnet kanject packages verify --advisories​# Optional: which packages would a change to Acme.Core reach?dotnet kanject packages bump preview --changed Acme.Core=patch

4. Adding a package? Decide whether it is public in packaging/kanject.packages.json, run plan --write so the public list changes in the same review, and add its ID to the expected set: the EXPECTED_PACKAGES variable on your own CI, or the release-surface diff that aws packages release pipeline up shows and applies on the hosted pipeline. A brand-new package keeps the version in its project file.

5. Release. How the versions get written depends on the host:

Which release host does the repository use?
Your own CI

Plan and apply the version bumps locally with credentials that can read the feeds. --all patch bumps every package whose contents changed since it was last published, and every package that depends on one; unchanged packages keep their versions. Use minor or major when the change calls for it, or name packages with --changed Acme.Sdk=minor. Planning builds the packages to compare them, so it can take several minutes on a large repository.

bash
dotnet kanject packages bump plan --all patchdotnet kanject packages bump apply package-bump-plan.jsongit commit -am "release: bump package versions"git push origin main# then run the release workflow and approve it

The plan file stays untracked. The build job stages the candidates and installs each one from the -rc feed; the publish job waits for your approval.

The hosted AWS pipeline

Commit everything first: the command refuses uncommitted files (KANCLI594) and a branch that doesn't contain main's tip (KANCLI606; fetch and rebase). Sign in to AWS, then run:

bash
# From the branch that carries the change, with everything committed.# The part comes from the declarations committed with the change: patch when there are none.dotnet kanject packages release​# Where does a commit stand, and what is the next human action?dotnet kanject packages release status
  • Resolves the declared part (patch when nothing is declared; --part can only raise it) and plans the version wave.
  • Commits the version bumps on a new release/<date>-<part> branch and pushes it, which starts the hosted rehearsal. A failed rehearsal is KANCLI595, and nothing is promoted.
  • Asks before fast-forwarding main to the rehearsed commit, which starts the release pipeline, then follows it until the wave parks at approval.

--no-promote stops after the rehearsal and prints the exact kanject aws pipeline promote command for later. Inside the repository, promote and status read the rehearsal project and region from packaging/release.pipeline.json, and --commit takes a full or abbreviated SHA:

bash
# Stop after the rehearsal; it prints the exact promote command to run laterdotnet kanject packages release --no-promote​# Inside the repository, project and region come from packaging/release.pipeline.jsondotnet kanject aws pipeline promote --from release/2026-10-15-patch --commit 27d546c62dotnet kanject aws pipeline status --commit 27d546c62

6. Approve and close out. Before approving, check that the commit is the one you meant to release, every gate is green, the packed set is the complete expected set, and the candidate smoke passed against the real -rc feed. After the publish, install one package from the release feed to confirm it restores.

Release one library at a time

By default a release treats the repository as one unit, so a fix to one package can bump every package that depends on a shared library it moved. --package <ID> narrows the release to that library plus every in-repo package it depends on. It works on packages release, bump preview, bump plan, verify, versions, and graph.

bash
# Release one library and the in-repo packages it depends ondotnet kanject packages release --package Acme.Payments​# Preview the scope first: its members, and the consumers it leaves behinddotnet kanject packages bump preview --all patch --package Acme.Payments
  • Consumers outside the scope stay on their published versions and pick up the new one at their own next release. The plan lists every consumer it leaves behind.
  • Leaving a consumer behind must be safe. A major release of a member that has consumers outside the scope, or an outside consumer whose version range excludes the new version, fails KANCLI601. Add the consumer with another --package, or release the full surface.
  • On the hosted pipeline the scope travels in the commit as packaging/release.scope.json, and declarations about libraries outside the scope wait for the release that ships them. The Build needs --scope-file and packages scope carry; aws packages release pipeline validate checks both.

If something fails

Every error carries a KANCLI### code and says how to fix it. The ones a new setup hits first are below; the diagnostic-code reference lists the rest.

  • KANCLI495 — a version already on a feed has different contents. The package changed without a version bump: run bump plan and bump apply, then release again.
  • KANCLI491 — the packed packages don't match EXPECTED_PACKAGES. Add or remove the ID in the release environment, after checking the change was intended.
  • KANCLI501 — a candidate could not be installed from the -rc feed. Check that every package it depends on is on the -rc feed, the release feed, or nuget.org.
  • KANCLI506 — a candidate installed but its consumer failed to build, often an analyzer or source generator that didn't load. For a package that only works alongside a companion package, packages smoke fixture init adds a test consumer that uses both.
  • KANCLI499 — publishing stopped partway. Fix the cause and re-run the publish job: packages already published with the same contents are skipped.
  • KANCLI488 / KANCLI502 — an expired CodeArtifact token or AWS sign-in. Run kanject doctor locally; it names the fix.
Recap
  • A two-line verify step stops a public package from depending on a private one.
  • A release runs as two jobs: an untrusted build that stages candidates testers can install, and a trusted publish that waits for approval.
  • bump plan and bump apply move dependent packages together; a published version never changes.
  • Day to day: pinned CLI, branch (declare a minor or major beside the code with intent add), gates, then bump plan + bump apply and your workflow, or packages release on the hosted pipeline.
  • --package <ID> releases one library and its in-repo dependencies; KANCLI601 stops a release that would break a consumer it leaves behind.
  • A publish that stops partway resumes: re-run it, and already-published packages are skipped.
Was this page helpful?