diff --git a/.shellspec b/.shellspec
index d56a9d9d..407bf88c 100644
--- a/.shellspec
+++ b/.shellspec
@@ -1,5 +1,5 @@
# kcov (coverage) options
---kcov-options "--include-pattern=build-poetry,config-uv,config-poetry,get-build-number,pr_cleanup,promote,build-gradle,config-maven,build-maven,config-npm,build-npm,build-yarn,shared,config-gradle,config-pip,update-release-channel,report-ci-metrics"
+--kcov-options "--include-pattern=build-poetry,config-uv,config-poetry,get-build-number,pr_cleanup,promote,build-gradle,config-maven,build-maven,config-npm,build-npm,build-yarn,shared,config-gradle,config-pip,update-release-channel,report-ci-metrics,config-nuget"
# --kcov-options "--exclude-pattern=.github,.idea,.git"
# define minimum coverage (fail otherwise)
diff --git a/README.md b/README.md
index ee1f452e..33d35589 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,7 @@ These badges show the status of workflows in dummy repositories that use (or sho
- [`build-yarn`](#build-yarn)
- [`config-pip`](#config-pip)
- [`config-uv`](#config-uv)
+- [`config-nuget`](#config-nuget)
- [`promote`](#promote)
- [`pr_cleanup`](#pr_cleanup)
- [`code-signing`](#code-signing)
@@ -1426,6 +1427,71 @@ See also [`get-build-number`](#get-build-number) output environment variables.
---
+## `config-nuget`
+
+Configure NuGet authentication and resolve the Repox package sources of NuGet configuration files through `repox-url`.
+
+The action reads a Repox reader token from Vault and exports it as `ARTIFACTORY_USER` and `ARTIFACTORY_PASSWORD`, the variables
+referenced by the `packageSourceCredentials` of the NuGet configuration files. With a `repox-url` other than `https://repox.jfrog.io`,
+every package source `value` starting with `https://repox.jfrog.io/artifactory/` in the listed files is rewritten to `repox-url`,
+so the committed files can keep SaaS Repox for local builds. Other attributes, such as `trustedSigners` service indexes, are unchanged.
+
+> **Note:** The listed files are rewritten in place, so the checkout has local modifications for the rest of the job. Do not commit them,
+> for example with `git add -A`, and exclude them from any diff check.
+
+Calling the action again in the same job, for example with other `nuget-config-files`, is supported: files that already resolve
+through `repox-url` are skipped. Unlike `config-maven` or `config-npm`, it therefore has no `CONFIG_NUGET_COMPLETED` guard.
+
+The action does not cache NuGet packages. The global packages folder or a `RestorePackagesPath` is specific to each repository, so
+cache it in the calling workflow, for example with [`SonarSource/gh-action_cache`](https://github.com/SonarSource/gh-action_cache)
+keyed on `packages.lock.json`.
+
+### Requirements
+
+#### Required GitHub Permissions
+
+- `id-token: write`
+
+#### Required Vault Permissions
+
+- `public-reader` or `private-reader`: Artifactory role for reading dependencies.
+
+### Usage
+
+```yaml
+permissions:
+ id-token: write
+steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: SonarSource/ci-github-actions/config-nuget@v2
+ with:
+ nuget-config-files: |
+ NuGet.Config
+ its/NuGet.config
+ - run: dotnet restore
+```
+
+### Inputs
+
+| Input | Description | Default |
+|---------------------------|---------------------------------------------------------------------------------------|----------------------------------------------------------------------|
+| `nuget-config-files` | NuGet configuration files to update (multiline), relative to `working-directory` | `NuGet.Config` |
+| `working-directory` | Relative path under github.workspace to execute the action in | `.` |
+| `artifactory-reader-role` | Suffix for the Artifactory reader role in Vault | `private-reader` for private repos, `public-reader` for public repos |
+| `repox-url` | URL for Repox | `https://repox.jfrog.io` |
+
+### Output Environment Variables
+
+| Environment Variable | Description |
+|----------------------------|---------------------------------------------|
+| `ARTIFACTORY_URL` | Repox Artifactory URL used for resolving |
+| `ARTIFACTORY_USERNAME` | Repox username |
+| `ARTIFACTORY_ACCESS_TOKEN` | Repox access token |
+| `ARTIFACTORY_USER` | Repox username for NuGet configurations |
+| `ARTIFACTORY_PASSWORD` | Repox access token for NuGet configurations |
+
+---
+
## `promote`
This action promotes a build in JFrog Artifactory and updates the GitHub status check accordingly.
diff --git a/config-nuget/action.yml b/config-nuget/action.yml
new file mode 100644
index 00000000..c6a215f8
--- /dev/null
+++ b/config-nuget/action.yml
@@ -0,0 +1,48 @@
+name: Config NuGet
+description: GitHub Action to configure NuGet authentication and resolve the Repox package sources of NuGet configuration files
+inputs:
+ nuget-config-files:
+ description: NuGet configuration files whose `https://repox.jfrog.io/artifactory` package sources resolve through `repox-url`
+ (multiline), relative to `working-directory`.
+ default: NuGet.Config
+ working-directory:
+ description: Relative path under github.workspace to execute the action in
+ default: .
+ artifactory-reader-role:
+ description: Suffix for the Artifactory reader role in Vault. Defaults to `private-reader` for private repositories, and `public-reader`
+ for public repositories.
+ default: ''
+ repox-url:
+ description: URL for Repox used to resolve dependencies. Use https://repox-internal.dev.sonar.build for the JFrog Edge.
+ default: https://repox.jfrog.io
+
+runs:
+ using: composite
+ steps:
+ - name: Set Artifactory reader role
+ shell: bash
+ env:
+ ARTIFACTORY_READER_ROLE: ${{ inputs.artifactory-reader-role != '' && inputs.artifactory-reader-role ||
+ (github.event.repository.visibility == 'public' && 'public-reader' || 'private-reader') }}
+ run: echo "ARTIFACTORY_READER_ROLE=${ARTIFACTORY_READER_ROLE}" >> "$GITHUB_ENV"
+
+ - uses: SonarSource/vault-action-wrapper@881045d830534a70ec3c7c275fa3714412c8ff6e # 3.6.1
+ id: secrets
+ with:
+ # yamllint disable rule:line-length
+ url: ${{ contains(inputs.repox-url, 'jfrog.io') && 'https://vault.sonar.build' || 'https://vault.dev.sonar.build' }}
+ secrets: |
+ development/${{ contains(inputs.repox-url, 'jfrog.io') && 'artifactory' || 'artifactory-edge-dev' }}/token/{REPO_OWNER_NAME_DASH}-${{ env.ARTIFACTORY_READER_ROLE }} username | ARTIFACTORY_USERNAME;
+ development/${{ contains(inputs.repox-url, 'jfrog.io') && 'artifactory' || 'artifactory-edge-dev' }}/token/{REPO_OWNER_NAME_DASH}-${{ env.ARTIFACTORY_READER_ROLE }} access_token | ARTIFACTORY_ACCESS_TOKEN;
+ # yamllint enable rule:line-length
+
+ - name: Configure NuGet
+ shell: bash
+ working-directory: ${{ inputs.working-directory }}
+ env:
+ ACTION_PATH_CONFIG_NUGET: ${{ github.action_path }}
+ ARTIFACTORY_URL: ${{ format('{0}/artifactory', inputs.repox-url) }}
+ ARTIFACTORY_USERNAME: ${{ steps.secrets.outputs.vault && fromJSON(steps.secrets.outputs.vault).ARTIFACTORY_USERNAME || '' }}
+ ARTIFACTORY_ACCESS_TOKEN: ${{ steps.secrets.outputs.vault && fromJSON(steps.secrets.outputs.vault).ARTIFACTORY_ACCESS_TOKEN || '' }}
+ NUGET_CONFIG_FILES: ${{ inputs.nuget-config-files }}
+ run: '"$ACTION_PATH_CONFIG_NUGET/nuget_config.sh"'
diff --git a/config-nuget/nuget_config.sh b/config-nuget/nuget_config.sh
new file mode 100755
index 00000000..aa06d7c5
--- /dev/null
+++ b/config-nuget/nuget_config.sh
@@ -0,0 +1,72 @@
+#!/bin/bash
+# Configure NuGet authentication and point the Repox package sources of NuGet configuration files at ARTIFACTORY_URL.
+#
+# Required environment variables (must be explicitly provided):
+# - ARTIFACTORY_URL: URL to Artifactory used to resolve packages
+# - ARTIFACTORY_USERNAME: Username to read Repox repositories
+# - ARTIFACTORY_ACCESS_TOKEN: Access token to read Repox repositories
+# - NUGET_CONFIG_FILES: Newline-separated NuGet configuration files to update
+#
+# GitHub Actions auto-provided:
+# - GITHUB_ENV: Path to GitHub Actions environment file
+
+set -euo pipefail
+
+: "${ARTIFACTORY_URL:?}" "${ARTIFACTORY_USERNAME:?}" "${ARTIFACTORY_ACCESS_TOKEN:?}" "${NUGET_CONFIG_FILES:?}" "${GITHUB_ENV:?}"
+
+readonly SAAS_ARTIFACTORY_URL="https://repox.jfrog.io/artifactory"
+
+export_credentials() {
+ {
+ echo "ARTIFACTORY_URL=$ARTIFACTORY_URL"
+ echo "ARTIFACTORY_USERNAME=$ARTIFACTORY_USERNAME"
+ echo "ARTIFACTORY_ACCESS_TOKEN=$ARTIFACTORY_ACCESS_TOKEN"
+ echo "ARTIFACTORY_USER=$ARTIFACTORY_USERNAME"
+ echo "ARTIFACTORY_PASSWORD=$ARTIFACTORY_ACCESS_TOKEN"
+ } >> "$GITHUB_ENV"
+ return 0
+}
+
+point_nuget_config() {
+ local config="$1"
+ local saas_pattern="value=\"${SAAS_ARTIFACTORY_URL//./\\.}/"
+ if [[ ! -f "$config" ]]; then
+ echo "::error::NuGet configuration file not found: $config" >&2
+ return 1
+ fi
+ if ! grep -q "$saas_pattern" "$config"; then
+ if grep -qF "value=\"${ARTIFACTORY_URL}/" "$config"; then
+ echo "Package sources of $config already resolve through $ARTIFACTORY_URL"
+ return 0
+ fi
+ echo "::error::No package source on $SAAS_ARTIFACTORY_URL in $config" >&2
+ return 1
+ fi
+ sed -i.bak "s#${saas_pattern}#value=\"${ARTIFACTORY_URL}/#g" "$config"
+ rm -f "$config.bak"
+ echo "Package sources of $config now resolve through $ARTIFACTORY_URL"
+ return 0
+}
+
+main() {
+ echo "::group::Configure NuGet"
+ export_credentials
+ if [[ "${ARTIFACTORY_URL%/}" == "$SAAS_ARTIFACTORY_URL" ]]; then
+ echo "NuGet configuration files already resolve through $SAAS_ARTIFACTORY_URL"
+ else
+ local config
+ while IFS= read -r config; do
+ config="${config%$'\r'}"
+ config="${config#"${config%%[![:space:]]*}"}"
+ config="${config%"${config##*[![:space:]]}"}"
+ [[ -z "$config" ]] && continue
+ point_nuget_config "$config" || return 1
+ done <<< "$NUGET_CONFIG_FILES"
+ fi
+ echo "::endgroup::"
+ return 0
+}
+
+if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
+ main
+fi
diff --git a/sonar-project.properties b/sonar-project.properties
index 85e0d272..1078f333 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -4,7 +4,7 @@ sonar.projectName=ci-github-actions
sonar.sourceEncoding=UTF-8
-sonar.sources=build-poetry,get-build-number,pr_cleanup,promote,build-gradle,build-maven,config-npm,build-npm,build-yarn,shared,config-pip,config-poetry,cache,code-signing,config-gradle,config-maven,check-sca,update-release-channel,report-ci-metrics
+sonar.sources=build-poetry,get-build-number,pr_cleanup,promote,build-gradle,build-maven,config-npm,build-npm,build-yarn,shared,config-pip,config-poetry,cache,code-signing,config-gradle,config-maven,check-sca,update-release-channel,report-ci-metrics,config-nuget
sonar.tests=spec
sonar.coverageReportPaths=coverage/coverage_data/sonar_coverage.xml
diff --git a/spec/config-nuget_spec.sh b/spec/config-nuget_spec.sh
new file mode 100644
index 00000000..25871222
--- /dev/null
+++ b/spec/config-nuget_spec.sh
@@ -0,0 +1,151 @@
+#!/usr/bin/env bash
+eval "$(shellspec - -c) exit 1"
+
+export GITHUB_ENV=/dev/null
+export ARTIFACTORY_URL="https://repox-internal.dev.sonar.build/artifactory"
+export ARTIFACTORY_USERNAME="reader"
+export ARTIFACTORY_ACCESS_TOKEN="reader-token"
+export NUGET_CONFIG_FILES="NuGet.Config"
+
+Describe 'config-nuget/nuget_config.sh'
+ It 'does not run main when sourced'
+ When run source config-nuget/nuget_config.sh
+ The status should be success
+ The output should equal ""
+ End
+End
+
+Include config-nuget/nuget_config.sh
+
+write_nuget_config() {
+ local nuget_config="$1"
+ cat > "$nuget_config" <<'EOF'
+
+
+
+
+
+
+
+
+
+
+
+EOF
+ return
+}
+
+common_setup() {
+ WORK_DIR=$(mktemp -d)
+ cd "$WORK_DIR" || exit 1
+ GITHUB_ENV=$(mktemp)
+ export GITHUB_ENV
+}
+
+common_cleanup() {
+ cd - > /dev/null || true
+ rm -rf "$WORK_DIR" "$GITHUB_ENV"
+ return
+}
+
+Describe 'export_credentials()'
+ BeforeEach 'common_setup'
+ AfterEach 'common_cleanup'
+
+ It 'exports the reader credentials for NuGet and other tools'
+ When call export_credentials
+ The status should be success
+ The contents of file "$GITHUB_ENV" should include "ARTIFACTORY_URL=https://repox-internal.dev.sonar.build/artifactory"
+ The contents of file "$GITHUB_ENV" should include "ARTIFACTORY_USERNAME=reader"
+ The contents of file "$GITHUB_ENV" should include "ARTIFACTORY_ACCESS_TOKEN=reader-token"
+ The contents of file "$GITHUB_ENV" should include "ARTIFACTORY_USER=reader"
+ The contents of file "$GITHUB_ENV" should include "ARTIFACTORY_PASSWORD=reader-token"
+ End
+End
+
+Describe 'point_nuget_config()'
+ BeforeEach 'common_setup'
+ AfterEach 'common_cleanup'
+
+ It 'points every Repox package source at ARTIFACTORY_URL'
+ write_nuget_config NuGet.Config
+ When call point_nuget_config NuGet.Config
+ The status should be success
+ The output should equal "Package sources of NuGet.Config now resolve through https://repox-internal.dev.sonar.build/artifactory"
+ The contents of file NuGet.Config should include 'value="https://repox-internal.dev.sonar.build/artifactory/api/nuget/v3/nuget/index.json"'
+ The contents of file NuGet.Config should include 'value="https://repox-internal.dev.sonar.build/artifactory/api/nuget/v3/sonarsource-nuget-releases/index.json"'
+ The contents of file NuGet.Config should include 'serviceIndex="https://repox.jfrog.io/artifactory/api/nuget/v3/nuget/index.json"'
+ The file NuGet.Config.bak should not be exist
+ End
+
+ It 'skips a file that already resolves through ARTIFACTORY_URL'
+ write_nuget_config NuGet.Config
+ point_nuget_config NuGet.Config > /dev/null
+ When call point_nuget_config NuGet.Config
+ The status should be success
+ The output should equal "Package sources of NuGet.Config already resolve through https://repox-internal.dev.sonar.build/artifactory"
+ The contents of file NuGet.Config should include 'value="https://repox-internal.dev.sonar.build/artifactory/api/nuget/v3/nuget/index.json"'
+ End
+
+ It 'fails when the file does not exist'
+ When call point_nuget_config missing/NuGet.Config
+ The status should be failure
+ The stderr should equal "::error::NuGet configuration file not found: missing/NuGet.Config"
+ End
+
+ It 'fails when the file has no Repox package source'
+ echo '' > NuGet.Config
+ When call point_nuget_config NuGet.Config
+ The status should be failure
+ The stderr should equal "::error::No package source on https://repox.jfrog.io/artifactory in NuGet.Config"
+ End
+End
+
+Describe 'main()'
+ BeforeEach 'common_setup'
+ AfterEach 'common_cleanup'
+
+ It 'updates every listed file and skips blank lines'
+ mkdir -p a b
+ write_nuget_config a/NuGet.Config
+ write_nuget_config b/nuget.config
+ export NUGET_CONFIG_FILES=$'a/NuGet.Config\r\n\n b/nuget.config \n'
+ When call main
+ The status should be success
+ The line 1 should equal "::group::Configure NuGet"
+ The line 2 should equal "Package sources of a/NuGet.Config now resolve through https://repox-internal.dev.sonar.build/artifactory"
+ The line 3 should equal "Package sources of b/nuget.config now resolve through https://repox-internal.dev.sonar.build/artifactory"
+ The line 4 should equal "::endgroup::"
+ The contents of file b/nuget.config should not include 'value="https://repox.jfrog.io/'
+ End
+
+ It 'keeps backslashes and apostrophes in listed paths'
+ write_nuget_config $'its\\NuGet.config'
+ mkdir -p "owner's"
+ write_nuget_config "owner's/NuGet.Config"
+ export NUGET_CONFIG_FILES=$'its\\NuGet.config\nowner'"'"'s/NuGet.Config'
+ When call main
+ The status should be success
+ The line 2 should equal $'Package sources of its\\NuGet.config now resolve through https://repox-internal.dev.sonar.build/artifactory'
+ The line 3 should equal "Package sources of owner's/NuGet.Config now resolve through https://repox-internal.dev.sonar.build/artifactory"
+ End
+
+ It 'leaves the files unchanged with SaaS Repox'
+ write_nuget_config NuGet.Config
+ export ARTIFACTORY_URL="https://repox.jfrog.io/artifactory"
+ export NUGET_CONFIG_FILES="NuGet.Config"
+ When call main
+ The status should be success
+ The line 2 should equal "NuGet configuration files already resolve through https://repox.jfrog.io/artifactory"
+ The contents of file NuGet.Config should include 'value="https://repox.jfrog.io/artifactory/api/nuget/v3/nuget/index.json"'
+ The contents of file "$GITHUB_ENV" should include "ARTIFACTORY_URL=https://repox.jfrog.io/artifactory"
+ End
+
+ It 'fails when a listed file is missing'
+ export NUGET_CONFIG_FILES="missing/NuGet.Config"
+ When run main
+ The status should be failure
+ The line 1 should equal "::group::Configure NuGet"
+ The stderr should equal "::error::NuGet configuration file not found: missing/NuGet.Config"
+ End
+End