From 518cd4e738c76291e3f89739299ffcd592c95891 Mon Sep 17 00:00:00 2001 From: Hedi Nasr Date: Thu, 1 Oct 2026 16:29:39 +0200 Subject: [PATCH] BUILD-12160 Add config-nuget to resolve NuGet packages through Repox Read the Repox reader token for repox-url, export it for the NuGet package source credentials, and rewrite the SaaS Repox package sources of the listed NuGet configuration files to repox-url. Files that already resolve through repox-url are skipped, so the action can be called again. --- .shellspec | 2 +- README.md | 66 +++++++++++++++ config-nuget/action.yml | 48 +++++++++++ config-nuget/nuget_config.sh | 72 +++++++++++++++++ sonar-project.properties | 2 +- spec/config-nuget_spec.sh | 151 +++++++++++++++++++++++++++++++++++ 6 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 config-nuget/action.yml create mode 100755 config-nuget/nuget_config.sh create mode 100644 spec/config-nuget_spec.sh 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