Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/test-update-release-channel.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ jobs:
test-urc-happy-path:
runs-on: sonar-xs
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./config-npm
- name: Update release channel (dry-run, happy path)
id: urc
uses: ./update-release-channel
Expand Down
2 changes: 1 addition & 1 deletion .shellspec
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# kcov (coverage) options
--kcov-options "--include-pattern=build-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"
--kcov-options "--include-pattern=build-poetry,config-uv,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"
# --kcov-options "--exclude-pattern=.github,.idea,.git"

# define minimum coverage (fail otherwise)
Expand Down
84 changes: 84 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ These badges show the status of workflows in dummy repositories that use (or sho
- [`build-npm`](#build-npm)
- [`build-yarn`](#build-yarn)
- [`config-pip`](#config-pip)
- [`config-uv`](#config-uv)
- [`promote`](#promote)
- [`pr_cleanup`](#pr_cleanup)
- [`code-signing`](#code-signing)
Expand Down Expand Up @@ -1180,6 +1181,89 @@ Both actions produce the same configuration and are functionally equivalent.

---

## `config-uv`

Configure uv build environment with build number, JFrog CLI authentication, and caching.

This action configures `jf config` for the Repox server.
It also sets native `UV_INDEX_*` credentials so uv resolves dependencies from Artifactory instead of PyPI.

There is no `jf uv-config` command. Configure indexes in `pyproject.toml` and run `jf uv` subcommands.

See the [JFrog uv documentation](https://docs.jfrog.com/artifactory/docs/jf-uv).

Repositories using uv should declare the Repox index in `pyproject.toml`:

```toml
[[tool.uv.index]]
name = "repox"
url = "https://repox.jfrog.io/artifactory/api/pypi/sonarsource-pypi/simple"
default = true
```

> **Note:** This action automatically calls [`get-build-number`](#get-build-number) to manage the build number.

### Requirements

#### Required GitHub Permissions

- `id-token: write`
- `contents: write`

#### Required Vault Permissions

- `public-reader` or `private-reader`: Artifactory role for reading dependencies

#### Other Dependencies

The `uv` tool must be pre-installed. Use of `mise` is recommended.

### Usage

```yaml
permissions:
id-token: write
contents: write
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
- uses: SonarSource/ci-github-actions/config-uv@v1
- run: jf uv sync
```

For build-info collection, pass `--build-name` and `--build-number` to `jf uv` and publish with `jf rt build-publish`.

### Inputs

| Input | Description | Default |
|---------------------------|-----------------------------------------------------------------------------|----------------------------------------------------------------------|
| `working-directory` | Relative path under github.workspace to execute the build in | `.` |
| `artifactory-reader-role` | Suffix for the Artifactory reader role in Vault | `private-reader` for private repos, `public-reader` for public repos |
| `uv-index-name` | Name of the uv index in `pyproject.toml` to authenticate | `repox` |
| `repox-url` | URL for Repox | `https://repox.jfrog.io` |
| `uv-cache-dir` | Path to the uv cache directory, relative to GitHub workspace | `.cache/uv` |
| `disable-caching` | Whether to disable uv caching entirely | `false` |

### Outputs

| Output | Description |
|----------------|---------------------------------------------------------------------------|
| `BUILD_NUMBER` | The current build number. Also set as environment variable `BUILD_NUMBER` |

### Output Environment Variables

| Environment Variable | Description |
|---------------------------------|--------------------------|
| `BUILD_NUMBER` | The current build number |
| `UV_INDEX_REPOX_USERNAME` | Repox username for uv (`repox` index name) |
| `UV_INDEX_REPOX_PASSWORD` | Repox access token for uv |
| `UV_KEYRING_PROVIDER` | Set to `disabled` when index credentials are injected |
| `UV_CACHE_DIR` | Path to the uv cache directory |

See also [`get-build-number`](#get-build-number) output environment variables.

---

## `promote`

This action promotes a build in JFrog Artifactory and updates the GitHub status check accordingly.
Expand Down
143 changes: 143 additions & 0 deletions config-uv/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
---
name: Config uv
description: GitHub Action to configure uv build environment with build number, Artifactory authentication, and caching
inputs:
working-directory:
description: Relative path under github.workspace to execute the build 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: ''
uv-index-name:
description: Name of the uv index in pyproject.toml to authenticate (e.g. repox for `[[tool.uv.index]]` with `name = "repox"`)
default: repox
repox-url:
description: URL for Repox
default: https://repox.jfrog.io
uv-cache-dir:
description: Path to the uv cache directory, relative to GitHub workspace
default: .cache/uv
disable-caching:
description: Whether to disable uv caching entirely
default: 'false'
host-actions-root:
description: Path to the actions folder on the host (used when called from another local action)
default: ''

outputs:
BUILD_NUMBER:
description: The current build number. Also set as environment variable BUILD_NUMBER
value: ${{ steps.get-build-number.outputs.BUILD_NUMBER }}

runs:
using: composite
steps:
- id: config-uv-completed
if: env.CONFIG_UV_COMPLETED != ''
shell: bash
run: |
echo "Action already called by $CONFIG_UV_COMPLETED, execution skipped."
echo "skip=true" >> "$GITHUB_OUTPUT"

- name: Set local action paths
id: set-path
if: steps.config-uv-completed.outputs.skip != 'true'
shell: bash
run: |
echo "::group::Fix for using local actions"
echo "GITHUB_ACTION_PATH=$GITHUB_ACTION_PATH"
echo "github.action_path=${{ github.action_path }}"
ACTION_PATH_CONFIG_UV="${{ github.action_path }}"
host_actions_root="${{ inputs.host-actions-root }}"
if [[ -z "$host_actions_root" ]]; then
host_actions_root="$(dirname "$ACTION_PATH_CONFIG_UV")"
else
ACTION_PATH_CONFIG_UV="$host_actions_root/config-uv"
fi
echo "ACTION_PATH_CONFIG_UV=$ACTION_PATH_CONFIG_UV"
echo "ACTION_PATH_CONFIG_UV=$ACTION_PATH_CONFIG_UV" >> "$GITHUB_ENV"
echo "host_actions_root=$host_actions_root" >> "$GITHUB_OUTPUT"

mkdir -p ".actions"
ln -sf "$host_actions_root/get-build-number" .actions/get-build-number
ln -sf "$host_actions_root/shared" .actions/shared
ls -la .actions/*
echo "::endgroup::"

echo "::group::Backup mise files to configure uv without interference"
mise_backup=$(mktemp -d)
echo "MISE_BACKUP=$mise_backup" >> "$GITHUB_OUTPUT"
mv mise.* .mise.* mise/ .mise/ .tool-versions "$mise_backup/" 2>/dev/null || true
cp "$ACTION_PATH_CONFIG_UV/mise.local.toml" mise.local.toml
echo "::endgroup::"
Comment on lines +68 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Bug: mise backup/restore use mismatched working directories

The mise backup logic and the restore logic run in different directories, which breaks when inputs.working-directory is not the default ..

  • The "Set local action paths" step (lines 68-73) has no working-directory, so it runs at the workspace root: it moves existing mise files into $mise_backup and cps mise.local.toml into the workspace root.
  • The "Configure uv authentication" step (lines 124-131) sets working-directory: ${{ inputs.working-directory }}, so the restore block (rm mise.local.toml, mv "$MISE_BACKUP"/* ./ ...) executes inside that subdirectory.

When working-directory is a subdirectory:

  1. rm mise.local.toml fails because the file was created at the workspace root, not the subdirectory. GitHub runs shell: bash with -eo pipefail, so this aborts the step and fails the whole action.
  2. Even if it didn't fail, the backed-up mise files would be restored into the wrong directory, leaving the workspace root permanently polluted with mise.local.toml and missing its original mise files.

Perform the backup and restore in the same directory. Either add working-directory: ${{ inputs.working-directory }} to the backup step, or remove working-directory from the configure step and have uv_config.sh/the build handle the path. Make sure the cp target and the rm/mv operate on the same location.

Run uv_config.sh in the working-directory but keep the mise restore at the workspace root where the backup/cp occurred.:

# In the "Configure uv authentication" step, drop the working-directory so
# backup (workspace root) and restore happen in the same place, OR run the
# backup at inputs.working-directory. Simplest: keep both at workspace root.
#
# backup step (no working-directory) already at workspace root -> keep restore there too:
    - name: Configure uv authentication
      if: steps.config-uv-completed.outputs.skip != 'true'
      shell: bash
      env:
        ...
      run: |
        (cd "${{ inputs.working-directory }}" && $ACTION_PATH_CONFIG_UV/uv_config.sh)

        echo "::group::Restore mise files"
        rm mise.local.toml
        mv "${{ steps.set-path.outputs.MISE_BACKUP }}"/* ./ 2>/dev/null || true
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎


- uses: ./.actions/get-build-number
id: get-build-number
if: steps.config-uv-completed.outputs.skip != 'true'
with:
host-actions-root: ${{ steps.set-path.outputs.host_actions_root }}

- name: Set Artifactory reader role
if: steps.config-uv-completed.outputs.skip != 'true'
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"

- name: Cache uv dependencies
uses: SonarSource/gh-action_cache@a7d13cdd1c9f097a5f8382ccec463be2831e3dbc # v1.6.0
if: steps.config-uv-completed.outputs.skip != 'true' && inputs.disable-caching == 'false'
with:
path: ${{ github.workspace }}/${{ inputs.uv-cache-dir }}
key: uv-${{ runner.os }}-${{ hashFiles(format('{0}/uv.lock', inputs.working-directory),
format('{0}/pyproject.toml', inputs.working-directory)) }}
restore-keys: uv-${{ runner.os }}-

- uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
if: steps.config-uv-completed.outputs.skip != 'true'
with:
version: 2026.5.9

- uses: SonarSource/vault-action-wrapper@0a3114fe1230b784c35b53b099f9ab1f1e538cc7 # 3.5.0
if: steps.config-uv-completed.outputs.skip != 'true'
id: secrets
with:
url: ${{ contains(inputs.repox-url, 'dev.sonar.build') && 'https://vault.dev.sonar.build' || 'https://vault.sonar.build' }}
secrets: |
development/artifactory/token/{REPO_OWNER_NAME_DASH}-${{ env.ARTIFACTORY_READER_ROLE }} username | ARTIFACTORY_USERNAME;
development/artifactory/token/{REPO_OWNER_NAME_DASH}-${{ env.ARTIFACTORY_READER_ROLE }} access_token | ARTIFACTORY_ACCESS_TOKEN;

- name: Configure uv authentication
if: steps.config-uv-completed.outputs.skip != 'true'
shell: bash
working-directory: ${{ inputs.working-directory }}
env:
ARTIFACTORY_URL: ${{ format('{0}/artifactory', inputs.repox-url) }}
ARTIFACTORY_USERNAME: ${{ fromJSON(steps.secrets.outputs.vault).ARTIFACTORY_USERNAME }}
ARTIFACTORY_ACCESS_TOKEN: ${{ fromJSON(steps.secrets.outputs.vault).ARTIFACTORY_ACCESS_TOKEN }}
UV_INDEX_NAME: ${{ inputs.uv-index-name }}
UV_CACHE_DIR: ${{ github.workspace }}/${{ inputs.uv-cache-dir }}
run: |
$ACTION_PATH_CONFIG_UV/uv_config.sh

echo "::group::Restore mise files"
rm mise.local.toml
mv "${{ steps.set-path.outputs.MISE_BACKUP }}"/* "${{ steps.set-path.outputs.MISE_BACKUP }}"/.* ./ 2>/dev/null || true
rmdir "${{ steps.set-path.outputs.MISE_BACKUP }}"
echo "::endgroup::"
Comment on lines +124 to +131

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Bug: mise restore not run when uv_config.sh fails or action errors

The mise restore block is appended after $ACTION_PATH_CONFIG_UV/uv_config.sh in the same run: step and is not guarded by always(). Because the step uses -eo pipefail, if uv_config.sh exits non-zero the restore lines never execute. This leaves the workspace with mise.local.toml copied in and the repo's original mise files (mise., .mise., .tool-versions) stranded in the temp backup directory. In self-hosted or reused checkouts this corrupts subsequent steps/runs. Consider moving the restore into a separate step with if: always() (and tolerating a missing backup), so cleanup happens even on failure.

Move restore into a dedicated always() step and use .[!.] to avoid matching . and .. when restoring dotfiles.:*

- name: Restore mise files
  if: always() && steps.config-uv-completed.outputs.skip != 'true'
  shell: bash
  run: |
    echo "::group::Restore mise files"
    rm -f mise.local.toml
    backup="${{ steps.set-path.outputs.MISE_BACKUP }}"
    if [[ -n "$backup" && -d "$backup" ]]; then
      mv "$backup"/* "$backup"/.[!.]* ./ 2>/dev/null || true
      rmdir "$backup" 2>/dev/null || true
    fi
    echo "::endgroup::"
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎


- name: Set Config uv completed
if: steps.config-uv-completed.outputs.skip != 'true'
shell: bash
run: echo "CONFIG_UV_COMPLETED=$GITHUB_ACTION" >> "$GITHUB_ENV"

- name: Clean up local action symlinks
if: always() && steps.config-uv-completed.outputs.skip != 'true'
shell: bash
run: |
rm -f .actions/get-build-number .actions/shared
rmdir .actions 2>/dev/null || true
6 changes: 6 additions & 0 deletions config-uv/mise.local.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[tools]
jfrog-cli = "2.96.0"

[env]
JFROG_CLI_AVOID_NEW_VERSION_WARNING = "true"
JFROG_CLI_ENV_EXCLUDE = "*password*;*secret*;*key*;*token*;*auth*;*credential*"
68 changes: 68 additions & 0 deletions config-uv/uv_config.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/bin/bash
# Config script for uv to use SonarSource Artifactory via JFrog CLI.
#
# There is no `jf uv-config` command. Configure `jf config` and declare
# `[[tool.uv.index]]` entries in pyproject.toml, then run `jf uv` subcommands.
# See https://docs.jfrog.com/artifactory/docs/jf-uv
#
# Required environment variables (must be explicitly provided):
# - ARTIFACTORY_URL: URL to Artifactory repository
# - ARTIFACTORY_USERNAME: Username for Artifactory authentication
# - ARTIFACTORY_ACCESS_TOKEN: Access token to read Repox repositories
# - UV_INDEX_NAME: Name of the uv index in pyproject.toml (e.g. repox)
#
# Optional environment variables:
# - UV_CACHE_DIR: Path to the uv cache directory
#
# GitHub Actions auto-provided:
# - GITHUB_ENV: Path to GitHub Actions environment file

set -euo pipefail

: "${ARTIFACTORY_URL:?}" "${ARTIFACTORY_USERNAME:?}" "${ARTIFACTORY_ACCESS_TOKEN:?}" "${UV_INDEX_NAME:?}" "${GITHUB_ENV:?}"

uv_index_env_suffix() {
local index_name_upper

index_name_upper=$(echo "$UV_INDEX_NAME" | tr '[:lower:]' '[:upper:]')
index_name_upper=$(echo "$index_name_upper" | tr -c 'A-Za-z0-9' '_' | sed 's/_*$//')
Comment on lines +27 to +28

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Edge Case: Degenerate uv-index-name yields malformed env var names

index_name_upper is built by uppercasing the index name and replacing non-alphanumerics with _, then stripping only trailing underscores via sed 's/_*$//'. For a normal name like repox or my-index this works (and the trailing-underscore strip correctly removes the _ produced from echo's newline). However, edge inputs are not guarded: an empty or all-symbol uv-index-name produces UV_INDEX__USERNAME/UV_INDEX__PASSWORD, and a name beginning with a digit (e.g. 2repox) produces UV_INDEX_2REPOX_.... uv would then look up a differently-named variable and authentication would silently fall back to anonymous/public PyPI access, causing confusing 401/resolution failures rather than a clear error. Consider validating that the sanitized index name is non-empty (and optionally that it matches uv's own naming rules) and failing fast with a descriptive message if not.

Strip leading underscores too and fail fast when the sanitized name is empty.:

index_name_upper=$(echo "$UV_INDEX_NAME" | tr '[:lower:]' '[:upper:]')
index_name_upper=$(echo "$index_name_upper" | tr -c 'A-Za-z0-9' '_' | sed 's/_*$//;s/^_*//')
if [[ -z "$index_name_upper" ]]; then
  echo "::error::Invalid uv-index-name '${UV_INDEX_NAME}': no usable characters for env var name" >&2
  return 1
fi
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

echo "$index_name_upper"
}

configure_uv_repox() {
local index_name_upper

echo "Configuring uv to use Artifactory via JFrog CLI..."

jf config remove repox > /dev/null 2>&1 || true # Ignore inexistent configuration
jf config add repox --url "${ARTIFACTORY_URL%/artifactory*}" --artifactory-url "$ARTIFACTORY_URL" --access-token "$ARTIFACTORY_ACCESS_TOKEN"
jf config use repox

index_name_upper=$(uv_index_env_suffix)

# Native uv variables for named indexes. Also set explicitly so plain `uv` works;
# `jf uv` injects the same variables when they are unset.
{
echo "UV_INDEX_${index_name_upper}_USERNAME=$ARTIFACTORY_USERNAME"
echo "UV_INDEX_${index_name_upper}_PASSWORD=$ARTIFACTORY_ACCESS_TOKEN"
echo "UV_KEYRING_PROVIDER=disabled"
} >> "$GITHUB_ENV"

if [[ -n "${UV_CACHE_DIR:-}" ]]; then
echo "UV_CACHE_DIR=$UV_CACHE_DIR" >> "$GITHUB_ENV"
mkdir -p "$UV_CACHE_DIR"
fi

return 0
}

main() {
echo "::group::Configure uv"
configure_uv_repox
echo "::endgroup::"
return 0
}

if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main
fi
Loading
Loading