Skip to content
Handing a package over

Handing a package over

A build that nobody else can consume is half a build. This chapter is the other half: how a .NET product publishes a library that a second product resolves, and how a C++ product hands a Conan package to one - which turns out to be a different act entirely, for a reason that has nothing to do with C++.

The short version

you have you publish with a consumer gets it with
a .NET library release:nuget build:nuget-config, then an ordinary restore
a Conan package release:conan build:conan-cache, then conan cache restore

Both read the manifest’s existing artifacts: section - the same one release:artifact reads. There is no new top-level key, and that is a decision: a section is cheap to add and expensive to remove, and a registry, a package name and a scope are the same three facts whatever the ecosystem.

NuGet: an ordinary registry

GitHub Packages serves NuGet natively, so both directions are ordinary commands against https://nuget.pkg.github.com/<owner>/index.json.

artifacts:
  corelib:
    registry: nuget.pkg.github.com/marcozwyssig
    image: mcr.microsoft.com/dotnet/sdk:9.0
    project: src/Core/Core.csproj
    tag: 1.4.0

release:nuget runs dotnet pack and then dotnet nuget push, both inside the pinned SDK image. The version comes from --tag if you pass one and from tag: if you do not - the same rule release:artifact follows, and for the same reason: some products know their number before the build and some only after it.

The credential, and why it is not in the file

build:nuget-config writes the product’s nuget.config. That file is meant to be committed, and it holds no token. What it holds is the name of an environment variable:

<packageSourceCredentials>
  <github>
    <add key="Username" value="marcozwyssig" />
    <add key="ClearTextPassword" value="%GITHUB_TOKEN%" />
  </github>
</packageSourceCredentials>

NuGet expands %GITHUB_TOKEN% from the environment when it reads the file. That was measured on Linux with the .NET 9 SDK rather than assumed, because the whole design rests on it: if it did not work, the only remaining options would put a secret either into a file somebody commits or onto a command line anybody with ps can read.

Three neighbouring things that do not work, so you do not spend the afternoon finding out.

dotnet nuget push --api-key <token> alone does not authenticate against a feed that wants an Authorization header. Measured: Unable to load the service index ... 401 (Unauthorized). An api key is not a credential.

The <apikeys> section of a nuget.config cannot hold a key on Linux at all. Measured: error: Encryption is not supported on non-Windows platforms. So you cannot move a key out of the command line by putting it in the config.

A push against a source that already carries credentials needs no api key. Measured: it warns No API Key was provided and pushes. That is why release:nuget puts nothing secret on a command line - there is nothing it has to put there.

The token itself reaches the container as a --env-file that is created 0600, used, and deleted in a finally - never as -e GITHUB_TOKEN=<value>, which docker inspect keeps and ps shows.

Consuming, which is a coordinate and not a toolchain command

build:nuget-restore exists because a product cannot declare it. A toolchain:run command’s environment is what the manifest names and nothing else, and a token is exactly what a manifest may not name. So the one step that has to carry a secret into a container is the kernel’s.

Two facts inherited from the uniform build, both of which cost a measurement there.

dotnet under a container running as the calling user needs DOTNET_CLI_HOME. As uid 1000 it dies on /.dotnet denied; as uid 0 it passes - which is why it had to be measured rather than reasoned about. Every container these coordinates start sets it.

The target framework must match the SDK image. net8.0 builds in a 9.0 image and then refuses to run with rc 150. Nothing in this kernel can check that for you: it is a property of your .csproj.

Conan: a transport, not a remote

GitHub Packages has no Conan registry. The kinds it serves are npm, RubyGems, Maven, Gradle, NuGet and Docker/Container - and nothing else. So the NuGet shape above simply cannot be built for Conan, and what release:conan does instead is move a file.

conan cache save writes the recipes and binaries of a package list to a .tgz. release:conan pushes that archive into a registry as an ordinary OCI artifact - the same mechanism release:artifact uses - and build:conan-cache pulls it back. The consumer then runs conan cache restore and builds against its own cache.

artifacts:
  cpplib:
    registry: ghcr.io/marcozwyssig
    repository: demo-conan
    source: build/conan/demo.tgz
    media_type: application/vnd.conan.cache.v1+tgz

Read this as a transport, because it is not a remote. There is no conan install resolution against the registry. There are no version ranges. No dependency graph is solved remotely. The consumer names an exact tag, pulls it, and restores what was in it.

If you design around “Conan packages in GitHub Packages” expecting the usual remote semantics, every one of those four sentences will surprise you later, in somebody else’s build.

Whether Conan could do better by itself

It is a fair question, and it was measured rather than looked up. Conan 2.32.0:

$ conan remote add --help
  -t {local-recipes-index}, --type {local-recipes-index}   Define the remote type

local-recipes-index is the only remote type Conan accepts. There is no OCI remote, no oci:// scheme and no container-registry backend, so pointing Conan at GHCR is not an option that was passed over - it does not exist. What would reopen this design is countable: a second value in that list.

Why the kernel does not run Conan for you

conan cache save and conan cache restore stay your own toolchain:run commands, against your own cache volume, with your own package pattern. The kernel moves a file and knows nothing about what is in it. A kernel that learned a package manager’s cache semantics would be a kernel that learned a language toolchain, which is the thing it refuses to do everywhere else.

Why the two halves are not symmetric

The .NET consumer needs the credential inside the container, because dotnet restore is what talks to the feed. The Conan consumer does not: the whole registry conversation happens on the host, and what reaches the container is a path. That asymmetry is the design, not an omission in one of the two.

When a publish is refused

Both halves send a GitHub token only to GitHub’s own hosts - ghcr.io and nuget.pkg.github.com. Point either at another registry and the answer is a refusal that names the host, not a token quietly handed to whoever the manifest named.

And the failure everyone hits once: gh auth login does not request the package scopes. The fix is in every message that could be caused by it -

gh auth refresh -h github.com -s read:packages,write:packages

read:packages is the one a consumer needs, which is worth knowing because the first person to hit it is usually not the person who published.

Why there is no Maven task and no PyPI task

The obvious next question about this chapter is why it has two rows and not four. .NET gets a task, C++ gets a task, and the two languages with the biggest package ecosystems on earth get nothing - which reads as a gap, and was filed as one.

It was measured before it was filled. On 2026-09-14, over the six products that install this kernel - their manifests, their build files and all fifteen of their CI workflows, read through the GitHub API:

looked for found
twine or a PyPI publish action none
a Gradle or Maven publish none
dotnet nuget push none
conan upload none
a manifest placing release:nuget or release:conan none

Not one product in the family publishes a language package. Not a wheel, not a jar, not a nupkg, not a Conan package. The two tasks this chapter documents are placed by nobody, and the demos they were shown on are throwaway trees.

The candidate the ticket named did not survive the measurement either. cleon is the family’s Java product that publishes, so it was the one to ask what a Maven publishing task would be worth - and cleon has no Maven build at all: it is an Eclipse/p2 project that publishes an update site. netctl’s Gradle build names mavenCentral() once, to read dependencies. The count of reachable products that could place a Maven publishing task is zero.

What every product does publish, and it is already the same for all four languages

  • a container image - release:image
  • a directory a pipeline pulls - release:artifact
  • files a person downloads - release:asset

None of the three knows what language built what it moves, and all twelve publishing commands across the family bind one of them or the product’s own body. That is where the symmetry is: Python and Java hand things over exactly the way .NET and C++ do. The asymmetry runs the other way - two languages have two extra tasks that nothing uses.

And Python’s answer was already given, on purpose

simplon publishes its own wheel and not through a task: .github/workflows/release.yml ends in pypa/gh-action-pypi-publish, and the manifest declares that workflow hand-written with its reason. PyPI’s trusted publishing exists only inside a CI run, so a publishing task a person could type would need an API token - the exact credential trusted publishing removes. A task there would not add a capability; it would add a secret.

Publishing a language package is a release task when a registry serves it and a workflow otherwise. release:nuget exists because GitHub Packages serves NuGet. release:conan exists because it does not serve Conan and a file had to be moved instead. Neither reason applies to a wheel. A third and a fourth task would make the family symmetric and the tally worse: four coordinates nobody places instead of two.