Skip to content

Commit c86b2bc

Browse files
feat(ENG-13680): add a NuGet credential provider
`cloudsmith credential-helper install nuget` writes a `nuget-plugin-cloudsmith` launcher (`.bat` on Windows) on PATH. NuGet 6.13+ finds it and speaks the cross-platform plugin protocol to `cloudsmith credential-helper nuget`. The plugin answers for Cloudsmith hosts and the NuGet domains of the Workspace that is written into the launcher. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 49dfad5 commit c86b2bc

13 files changed

Lines changed: 1607 additions & 19 deletions

File tree

‎.github/workflows/nuget-e2e.yml‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
name: nuget-e2e
2+
3+
on:
4+
push:
5+
branches:
6+
- copilot/create-nuget-credential-provider
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
e2e:
13+
name: ${{ matrix.os }} / ${{ matrix.case }} / ${{ matrix.mode }}
14+
strategy:
15+
fail-fast: false
16+
matrix:
17+
os: [ubuntu-24.04, macos-14, windows-2025]
18+
case: [standard, custom]
19+
mode: [source, frozen]
20+
runs-on: ${{ matrix.os }}
21+
timeout-minutes: 30
22+
defaults:
23+
run:
24+
shell: bash
25+
env:
26+
RAW_API_HOST: ${{ vars.PYTEST_CLOUDSMITH_API_HOST }}
27+
CLOUDSMITH_API_KEY: ${{ matrix.case == 'custom' && secrets.PYTEST_CLOUDSMITH_CUSTOM_DOMAIN_API_KEY || secrets.PYTEST_CLOUDSMITH_API_KEY }}
28+
CLOUDSMITH_NO_KEYRING: "1"
29+
WORKSPACE: ${{ matrix.case == 'custom' && vars.PYTEST_CLOUDSMITH_CUSTOM_DOMAIN_WORKSPACE || vars.PYTEST_CLOUDSMITH_ORGANIZATION }}
30+
REPO: ${{ matrix.case == 'custom' && 'cli-pytest-custom-domain' || 'cli-pytest-nuget' }}
31+
CASE: ${{ matrix.case }}
32+
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
33+
DOTNET_NOLOGO: "1"
34+
steps:
35+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
36+
with:
37+
persist-credentials: false
38+
39+
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
40+
with:
41+
python-version: "3.14"
42+
43+
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
44+
45+
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
46+
with:
47+
dotnet-version: "10.0.x"
48+
49+
- name: Install the CLI from source
50+
if: matrix.mode == 'source'
51+
run: |
52+
set -euo pipefail
53+
uv sync --locked --no-dev --python 3.14
54+
if [ "$RUNNER_OS" = Windows ]; then bin=Scripts; else bin=bin; fi
55+
echo "$GITHUB_WORKSPACE/.venv/$bin" >> "$GITHUB_PATH"
56+
57+
- name: Build the frozen CLI
58+
if: matrix.mode == 'frozen'
59+
env:
60+
PYINSTALLER_CONFIG_DIR: ${{ runner.temp }}/pyinstaller
61+
run: |
62+
set -euo pipefail
63+
uv sync --locked --no-dev --no-editable --group binary --extra all --python 3.14
64+
uv run --no-sync pyinstaller --clean --noconfirm packaging/pyinstaller/cloudsmith.spec
65+
mv dist/cloudsmith "$RUNNER_TEMP/cloudsmith-frozen"
66+
echo "$RUNNER_TEMP/cloudsmith-frozen" >> "$GITHUB_PATH"
67+
68+
- name: Find the feed
69+
run: |
70+
set -euo pipefail
71+
export CLOUDSMITH_API_HOST="${RAW_API_HOST%/v1}"
72+
echo "CLOUDSMITH_API_HOST=$CLOUDSMITH_API_HOST" >> "$GITHUB_ENV"
73+
which cloudsmith
74+
cloudsmith --version
75+
cloudsmith domains list --workspace "$WORKSPACE" > domains.json
76+
feed=$(python - <<'EOF'
77+
import json, os
78+
data = json.load(open("domains.json"))
79+
data = data.get("data", data)
80+
rows = [d for d in data["domains"] if d["format"] == "nuget"]
81+
if os.environ["CASE"] == "custom":
82+
host = next(d["host"] for d in rows if d["type"] == "custom")
83+
print(f"https://{host}/{os.environ['REPO']}/v3/index.json")
84+
else:
85+
host = next(d["host"] for d in rows if d["type"] == "default")
86+
print(f"https://{host}/{os.environ['WORKSPACE']}/{os.environ['REPO']}/v3/index.json")
87+
EOF
88+
)
89+
echo "FEED=$feed" >> "$GITHUB_ENV"
90+
91+
- name: Write a consumer project with no feed credentials
92+
run: |
93+
set -euo pipefail
94+
mkdir -p consumer
95+
cat > consumer/Consumer.csproj <<'EOF'
96+
<Project Sdk="Microsoft.NET.Sdk">
97+
<PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup>
98+
<ItemGroup><PackageReference Include="Cloudsmith.Cli.NuGetProbe" Version="1.0.0" /></ItemGroup>
99+
</Project>
100+
EOF
101+
cat > nuget.config <<EOF
102+
<configuration><packageSources><clear /><add key="cloudsmith" value="$FEED" /></packageSources></configuration>
103+
EOF
104+
105+
- name: Restore fails without the plugin
106+
run: |
107+
set +e
108+
out=$(NUGET_PACKAGES="$RUNNER_TEMP/pkgs-denied" dotnet restore consumer --configfile nuget.config 2>&1)
109+
code=$?
110+
echo "$out"
111+
test "$code" -ne 0
112+
echo "$out" | grep -q 401
113+
114+
- name: Install the plugin
115+
run: |
116+
set -euo pipefail
117+
# CLOUDSMITH_API_HOST comes from the environment, so the launcher bakes it in.
118+
cloudsmith credential-helper install nuget --workspace "$WORKSPACE" --bin-dir "$RUNNER_TEMP/nuget-bin"
119+
ls -la "$RUNNER_TEMP/nuget-bin"
120+
cat "$RUNNER_TEMP/nuget-bin"/nuget-plugin-cloudsmith*
121+
echo "$RUNNER_TEMP/nuget-bin" >> "$GITHUB_PATH"
122+
cloudsmith credential-helper list
123+
124+
- name: Restore succeeds with the plugin
125+
env:
126+
NUGET_PLUGINS_CACHE_PATH: ${{ runner.temp }}/plugins-cache
127+
run: |
128+
set -euo pipefail
129+
# Only the launcher knows the API host and the Workspace.
130+
env -u CLOUDSMITH_API_HOST -u WORKSPACE NUGET_PACKAGES="$RUNNER_TEMP/pkgs-allowed" \
131+
dotnet restore consumer --configfile nuget.config -v normal
132+
test -d "$RUNNER_TEMP/pkgs-allowed/cloudsmith.cli.nugetprobe/1.0.0"

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Added a NuGet credential provider. Run `cloudsmith credential-helper install nuget --workspace <workspace>` once. Then `dotnet restore`, MSBuild and Visual Studio authenticate to Cloudsmith feeds with your CLI credentials. They find the provider on PATH (.NET SDK 9.0.200 or later). The provider also covers the NuGet custom domains of the Workspace. No token is stored in `nuget.config`.
13+
1014
### Changed
1115

1216
- `cloudsmith domains list` and the credential helpers now get the domains from `GET /v2/workspaces/{workspace}/domains/` on the configured API host. This list includes the Cloudsmith-owned hosts, so the CLI no longer contains a list of Cloudsmith hostnames. The CLI keeps a separate domain cache for each API host.

‎cloudsmith_cli/cli/commands/credential_helper/__init__.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .docker import docker as docker_cmd
1414
from .generic import generic as generic_cmd
1515
from .manage import install_cmd, list_cmd, uninstall_cmd
16+
from .nuget import nuget as nuget_cmd
1617
from .pnpm import pnpm as pnpm_cmd
1718
from .terraform import terraform as terraform_cmd
1819

@@ -37,6 +38,9 @@ def credential_helper():
3738
\b
3839
# Install cargo credential helper
3940
$ cloudsmith credential-helper install cargo
41+
\b
42+
# Install NuGet credential provider
43+
$ cloudsmith credential-helper install nuget
4044
\b
4145
# Test Docker credential helper directly
4246
$ echo "docker.cloudsmith.io" | cloudsmith credential-helper docker
@@ -54,5 +58,6 @@ def credential_helper():
5458
credential_helper.add_command(list_cmd, name="list")
5559
credential_helper.add_command(cargo_cmd, name="cargo")
5660
credential_helper.add_command(terraform_cmd, name="terraform")
61+
credential_helper.add_command(nuget_cmd, name="nuget")
5762

5863
main.add_command(credential_helper, name="credential-helper")

‎cloudsmith_cli/cli/commands/credential_helper/manage.py‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,19 @@
88

99
from __future__ import annotations
1010

11+
import os
1112
import sys
1213
from pathlib import Path
1314

1415
import click
16+
from click.core import ParameterSource
1517

1618
from cloudsmith_cli.credential_helpers.cargo.installer import CargoInstaller
1719
from cloudsmith_cli.credential_helpers.generic import PartialInstallError
20+
from cloudsmith_cli.credential_helpers.nuget.installer import (
21+
NuGetInstaller,
22+
NuGetInstallError,
23+
)
1824
from cloudsmith_cli.credential_helpers.pnpm.installer import PNPMInstaller
1925
from cloudsmith_cli.credential_helpers.terraform.installer import (
2026
TerraformHelperExeNotFound,
@@ -44,6 +50,7 @@
4450
"pnpm": PNPMInstaller,
4551
"cargo": CargoInstaller,
4652
"terraform": TerraformInstaller,
53+
"nuget": NuGetInstaller,
4754
}
4855

4956

@@ -118,6 +125,51 @@ def _terraform_helper_args(ctx, opts, repo: str | None = None) -> tuple[str, ...
118125
return tuple(args)
119126

120127

128+
# Settings that have no CLI flag but change where the plugin finds credentials.
129+
_NUGET_KEYRING_PATHS = ("CLOUDSMITH_KEYRING_FILE_PATH", "CLOUDSMITH_KEYRING_DIR")
130+
_NUGET_ENV_ONLY_SETTINGS = (
131+
"CLOUDSMITH_NO_KEYRING",
132+
"CLOUDSMITH_KEYRING_BACKEND",
133+
"CLOUDSMITH_KEYRING_FILE_PATH",
134+
"CLOUDSMITH_KEYRING_DIR",
135+
)
136+
137+
138+
def _nuget_launcher_env(ctx, opts) -> dict[str, str]:
139+
"""Return the ``CLOUDSMITH_*`` settings to write into the NuGet launcher.
140+
141+
NuGet starts the plugin from IDEs and build agents that do not have this
142+
shell's environment. Only values from a flag or an environment variable
143+
are kept, because the plugin reads the config files itself. Secrets such
144+
as the API key are never kept.
145+
"""
146+
supplied = (ParameterSource.COMMANDLINE, ParameterSource.ENVIRONMENT)
147+
148+
def from_flag_or_env(param: str, value):
149+
return value if ctx.get_parameter_source(param) in supplied else None
150+
151+
settings = {
152+
"CLOUDSMITH_WORKSPACE": from_flag_or_env("org", opts.org),
153+
"CLOUDSMITH_PROFILE": ctx.meta.get("profile"),
154+
"CLOUDSMITH_CONFIG_FILE": ctx.meta.get("config_file"),
155+
"CLOUDSMITH_CREDENTIALS_FILE": ctx.meta.get("creds_file"),
156+
"CLOUDSMITH_API_HOST": from_flag_or_env("api_host", opts.api_host),
157+
"CLOUDSMITH_API_PROXY": from_flag_or_env("api_proxy", opts.api_proxy),
158+
"CLOUDSMITH_WITHOUT_API_SSL_VERIFY": from_flag_or_env(
159+
"without_api_ssl_verify", "true" if opts.api_ssl_verify is False else None
160+
),
161+
**{name: os.environ.get(name) for name in _NUGET_ENV_ONLY_SETTINGS},
162+
}
163+
for name in _NUGET_KEYRING_PATHS:
164+
path = settings[name]
165+
if path:
166+
# NuGet runs the plugin without this shell's variables or directory.
167+
settings[name] = os.path.abspath(
168+
os.path.expanduser(os.path.expandvars(path))
169+
)
170+
return {name: value for name, value in settings.items() if value}
171+
172+
121173
_REPO_FLAGS = ("-r", "--repo", "--repository")
122174

123175

@@ -268,13 +320,16 @@ def install_cmd(
268320
if helper == "terraform":
269321
helper_args = _terraform_helper_args(ctx, opts, repo)
270322
install_kwargs["helper_args"] = helper_args
323+
elif helper == "nuget":
324+
install_kwargs["launcher_env"] = _nuget_launcher_env(ctx, opts)
271325

272326
try:
273327
actions = installer.install(**install_kwargs)
274328
except (
275329
TerraformrcConflictError,
276330
TerraformHelperExeNotFound,
277331
TerraformPluginDirError,
332+
NuGetInstallError,
278333
) as exc:
279334
raise click.ClickException(str(exc))
280335
except OSError as exc:
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Copyright 2026 Cloudsmith Ltd
2+
"""
3+
NuGet credential provider command.
4+
5+
Implements the NuGet cross-platform authentication plugin protocol for
6+
Cloudsmith feeds.
7+
8+
See:
9+
https://learn.microsoft.com/en-us/nuget/reference/extensibility/nuget-cross-platform-plugins
10+
"""
11+
12+
import sys
13+
14+
import click
15+
16+
from ....core.credentials.chain import CredentialProviderChain
17+
from ....credential_helpers.nuget import execute
18+
from ...decorators import common_api_auth_options, initialise_api
19+
20+
21+
def _utf8(stream, **kwargs):
22+
"""Switch a text stream to UTF-8, which the NuGet plugin protocol requires."""
23+
try:
24+
stream.reconfigure(encoding="utf-8", **kwargs)
25+
except (AttributeError, ValueError, OSError):
26+
pass
27+
return stream
28+
29+
30+
@click.command(
31+
context_settings={"ignore_unknown_options": True, "allow_interspersed_args": False}
32+
)
33+
@click.argument("nuget_args", nargs=-1, type=click.UNPROCESSED)
34+
@common_api_auth_options
35+
@initialise_api
36+
def nuget(opts, nuget_args):
37+
"""
38+
NuGet credential provider for Cloudsmith feeds.
39+
40+
NuGet (dotnet, MSBuild, Visual Studio) runs this command through the
41+
``nuget-plugin-cloudsmith`` launcher that ``cloudsmith credential-helper
42+
install nuget`` puts on PATH, passing ``-Plugin`` after ``--``. It then
43+
speaks the NuGet cross-platform plugin protocol on stdin/stdout.
44+
45+
Provides credentials for all Cloudsmith NuGet feeds: ``*.cloudsmith.io``,
46+
``*.cloudsmith.com``, and any NuGet custom domains configured for the
47+
Workspace (requires a Workspace - ``--workspace``, CLOUDSMITH_WORKSPACE,
48+
or ``workspace`` in ``config.ini``; legacy aliases are also accepted - and
49+
a valid API key/token). Any other feed is declined so NuGet falls back to
50+
its other credential sources.
51+
52+
\b
53+
Examples:
54+
55+
\b
56+
# Called by NuGet via the launcher
57+
$ nuget-plugin-cloudsmith -Plugin
58+
59+
\b
60+
Environment variables:
61+
CLOUDSMITH_API_KEY: API key for authentication (optional)
62+
CLOUDSMITH_WORKSPACE: Workspace slug (CLOUDSMITH_ORG is also accepted)
63+
"""
64+
exit_code, stderr = execute(
65+
nuget_args,
66+
_utf8(sys.stdin),
67+
_utf8(sys.stdout, newline="\n"),
68+
credential=opts.credential,
69+
api_host=opts.api_host,
70+
workspace=opts.org,
71+
refresh_credential=lambda: CredentialProviderChain().resolve(
72+
opts.credential_context
73+
),
74+
)
75+
76+
if stderr is not None:
77+
click.echo(stderr, err=True)
78+
sys.exit(exit_code)

‎cloudsmith_cli/cli/decorators.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,7 @@ def wrapper(ctx, *args, **kwargs):
586586
click.secho(message, fg="yellow", err=True)
587587

588588
opts.credential = credential
589+
opts.credential_context = context
589590

590591
kwargs["opts"] = opts
591592
return ctx.invoke(f, *args, **kwargs)

0 commit comments

Comments
 (0)