Skip to content
Merged
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
33 changes: 24 additions & 9 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ on:
types:
- completed

permissions:
contents: read

concurrency:
group: cd-${{ github.ref }}
cancel-in-progress: false

env:
CD: ${{ vars.CONTINUOUS_DEPLOYMENT }}
VERSION: ${{ github.event.inputs.version != '' && github.event.inputs.version || 'None' }}
Expand All @@ -33,35 +40,40 @@ jobs:
os: [ubuntu-latest]
platforms: [linux/amd64]
steps:
# Publish the exact commit that passed CI rather than whatever main points at now.
- name: Checkout Git repository
uses: actions/checkout@v6.0.3
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
fetch-depth: 0

- name: Install uv and set the Python version
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0

- name: Install dependencies
shell: bash
run: uvx uvtask dev-install
run: uvx uvtask ci-install

- name: Set version
shell: bash
env:
GIT_ACTOR: ${{ github.actor }}
GIT_ACTOR_ID: ${{ github.actor_id }}
run: |
if [ -z "${VERSION}" ] || [ "${VERSION}" = "None" ]; then
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0")
uv version ${LAST_TAG}
uv version "${LAST_TAG}"
uv version --bump minor
VERSION=$(uv version --short)
echo "VERSION=${VERSION}" >> "${GITHUB_ENV:-/dev/null}"
else
uv version ${VERSION}
uv version "${VERSION}"
fi
git config user.name "${{ github.actor }}"
git config user.email "${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com"
git config user.name "${GIT_ACTOR}"
git config user.email "${GIT_ACTOR_ID}+${GIT_ACTOR}@users.noreply.github.com"
git tag -a "${VERSION}" -m "Release ${VERSION}"
git push origin "${VERSION}"

- name: Build package
shell: bash
run: uv build
Expand All @@ -74,8 +86,11 @@ jobs:

- name: Publish package
shell: bash
run: uv publish --token "${{ secrets.UV_PUBLISH_TOKEN }}"
env:
UV_PUBLISH_TOKEN: ${{ secrets.UV_PUBLISH_TOKEN }}
run: uv publish

- name: Clean
if: always()
shell: bash
run: uvx uvtask clean
23 changes: 9 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ on:
default: "None"
required: false

permissions:
contents: read

env:
CI: "true"

Expand All @@ -23,7 +26,6 @@ jobs:
permissions:
contents: read
env:
PIPELINE_TESTS: ${{ github.event_name != 'workflow_dispatch' && github.event.inputs.version == '' && startsWith(github.ref, 'refs/tags/') == false && github.ref != 'refs/heads/main' && 'true' || 'false' }}
RELEASE_MODE: "false"
VERSION: ${{ github.run_id }}
timeout-minutes: 15
Expand All @@ -34,62 +36,55 @@ jobs:
platforms: [linux/amd64, linux/arm64]
steps:
- name: Checkout Git repository
uses: actions/checkout@v6.0.3
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0

persist-credentials: false

- name: Install uv and set the Python version
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0

- name: Install dependencies
shell: bash
run: uvx uvtask dev-install
run: uvx uvtask ci-install

- name: security-analysis-licenses
shell: bash
run: uvx uvtask security-analysis:licenses
if: ${{ env.PIPELINE_TESTS == 'true' }}

- name: security-analysis-vulnerabilities-code
shell: bash
run: uvx uvtask security-analysis:vulnerabilities:code
if: ${{ env.PIPELINE_TESTS == 'true' }}

- name: security-analysis-vulnerabilities-pkgs
shell: bash
run: uvx uvtask security-analysis:vulnerabilities:pkgs
if: ${{ env.PIPELINE_TESTS == 'true' }}

- name: static-analysis-linter
shell: bash
run: uvx uvtask static-analysis:linter
if: ${{ env.PIPELINE_TESTS == 'true' }}

- name: static-analysis-types
shell: bash
run: uvx uvtask static-analysis:types
if: ${{ env.PIPELINE_TESTS == 'true' }}

- name: complexity-visibility
shell: bash
run: uvx uvtask complexity:visibility
if: ${{ env.PIPELINE_TESTS == 'true' }}

- name: complexity-enforcement
shell: bash
run: uvx uvtask complexity:enforcement
if: ${{ env.PIPELINE_TESTS == 'true' }}

- name: unit-tests
shell: bash
run: uvx uvtask unit-tests
if: ${{ env.PIPELINE_TESTS == 'true' }}


- name: integration-tests
shell: bash
run: uvx uvtask integration-tests
if: ${{ env.PIPELINE_TESTS == 'true' }}

- name: Clean
if: always()
shell: bash
run: uvx uvtask clean
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,28 @@ static-analysis = { command = ["static-analysis:linter", "static-analysis:types"
| `-V` / `--version` | Print version |
| `-h` / `--help` | Print general help |

## Security model

`uvtask` runs the shell strings written in `pyproject.toml`, so a project's manifest is trusted
exactly like a `Makefile` or an npm script: opening a repository is safe, running one of its
commands is not. Treat `uvx uvtask <command>` in an unfamiliar repository the same way you would
treat `npm run`.

Everything else is treated as untrusted:

- **Forwarded arguments** are quoted before reaching the shell — `shlex.join` on Unix, and
`list2cmdline` plus caret-escaping of `cmd.exe` metacharacters on Windows — so a value like
`foo&whoami` is passed through as data rather than run as a second command.
- **Script names and descriptions** are stripped of terminal control characters before display,
so a manifest cannot rewrite what you see in `--help` or `uvtask help <command>`.
- **Malformed scripts are rejected** rather than coerced. A table without a `command` key, a
non-string command, a circular reference, or a reference graph that expands past 512 commands
fails with a config error instead of being handed to the shell.

Hooks are skipped only by `--no-hooks` / `--ignore-scripts` placed *before* the command name.
The same flag after the command name is forwarded to the underlying script, so arguments meant
for a child process cannot silently bypass a guard hook.

## Comparison

| Tool | Best for | uvtask difference |
Expand Down
9 changes: 6 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ lint.select = [
]
lint.ignore = [
"PLR0913",
"PLR2004",
"PLR0917",
"PLR2004",
]

[tool.ruff.format]
Expand All @@ -139,10 +140,12 @@ exclude = [
]

[tool.run-script]
install = { command = "uv sync --frozen --no-dev", description = "Install dependencies as specified in lockfile, excluding dev dependencies" }
upgrade-install = { command = "uv sync --frozen --no-dev --upgrade --refresh", description = "Upgrade and refresh installation of non-dev dependencies" }
install = { command = "uv sync --no-dev", description = "Install dependencies as specified in lockfile, excluding dev dependencies" }
upgrade-install = { command = "uv sync --no-dev --upgrade --refresh", description = "Upgrade and refresh installation of non-dev dependencies" }
dev-install = { command = "uv sync --dev --all-extras", description = "Install all dependencies including dev and extras" }
ci-install = { command = "uv sync --frozen --dev --all-extras", description = "Install all dependencies from the lockfile without updating it" }
upgrade-dev-install = { command = "uv sync --dev --all-extras --upgrade --refresh", description = "Upgrade and refresh installation of all dependencies including dev and extras" }
cve-upgrade = { command = """uv run --no-project --quiet python -c "import os, subprocess, sys; pkgs = sys.argv[1:]; sys.exit(subprocess.run(['uv', 'lock', *[arg for pkg in pkgs for arg in ('--upgrade-package', pkg, '--exclude-newer-package', f'{pkg}=false')]], env={**os.environ, 'UV_EXCLUDE_NEWER': 'P0D'}).returncode if pkgs else print('Usage: uvtask cve-upgrade PACKAGE [PACKAGE ...]', file=sys.stderr) or 1)" """, description = "Relock the given packages against the newest releases to pick up CVE fixes" }
code-formatter = { command = "uv run ruff format uvtask tests", description = "Format code with ruff" }
security-analysis = { command = ["security-analysis:licenses", "security-analysis:vulnerabilities"], description = "Run all security analysis checks" }
"security-analysis:licenses" = { command = "uv run pip-licenses", description = "Check third-party dependencies licenses using pip-licenses" }
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def test_run_executes_command(self, pyproject_toml: Path) -> None:
mock_parser = MagicMock()
mock_parser_builder.build_main_parser.return_value = mock_parser
mock_argv_parser = MagicMock()
mock_argv_parser.parse_global_options.return_value = ("test", [], 0, 0)
mock_argv_parser.parse_global_options.return_value = ("test", [], 0, 0, False)
mock_validator = MagicMock()
mock_builder = MagicMock()
mock_builder.build_commands.return_value = ["echo test"]
Expand Down Expand Up @@ -120,7 +120,7 @@ def test_run_handles_help_command(self, pyproject_toml: Path) -> None:
mock_parser = MagicMock()
mock_parser_builder.build_main_parser.return_value = mock_parser
mock_argv_parser = MagicMock()
mock_argv_parser.parse_global_options.return_value = ("help", [], 0, 0)
mock_argv_parser.parse_global_options.return_value = ("help", [], 0, 0, False)
mock_validator = MagicMock()
mock_builder = MagicMock()
mock_help_handler = MagicMock()
Expand Down
78 changes: 66 additions & 12 deletions tests/unit/test_commands.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,20 @@
import sys
from shlex import join as shlex_join
from subprocess import list2cmdline
from unittest.mock import MagicMock, patch

import pytest

from uvtask.commands import (
MAX_RESOLUTION_DEPTH,
CommandBuilder,
CommandExecutorOrchestrator,
CommandValidator,
HelpCommandHandler,
VerboseOutputHandler,
_join_script_args,
_quote_for_cmd,
)


def _expected_script_args_str(script_args: list[str]) -> str:
if sys.platform == "win32":
return list2cmdline(script_args)
return shlex_join(script_args)


class TestCommandBuilder:
def test_build_string_command(self) -> None:
builder = CommandBuilder()
Expand Down Expand Up @@ -66,19 +61,78 @@ def test_build_invalid_type(self) -> None:
with pytest.raises(ValueError, match="Invalid script format"):
builder.build_commands(123, []) # ty: ignore[invalid-argument-type]

def test_build_referenced_invalid_type(self) -> None:
builder = CommandBuilder()
with pytest.raises(ValueError, match="Invalid script format"):
builder.build_commands("broken", [], {"broken": 123}) # ty: ignore[invalid-argument-type]

def test_build_rejects_exponential_expansion(self) -> None:
builder = CommandBuilder()
depth = 24
scripts: dict[str, str | list[str]] = {f"s{i}": [f"s{i + 1}", f"s{i + 1}"] for i in range(depth)}
scripts[f"s{depth}"] = "echo leaf"
with pytest.raises(ValueError, match="expands to more than"):
builder.build_commands("s0", [], scripts)

def test_build_rejects_deep_nesting(self) -> None:
builder = CommandBuilder()
depth = MAX_RESOLUTION_DEPTH + 2
scripts: dict[str, str | list[str]] = {f"s{i}": f"s{i + 1}" for i in range(depth)}
scripts[f"s{depth}"] = "echo leaf"
with pytest.raises(ValueError, match="references deep"):
builder.build_commands("s0", [], scripts)

def test_build_command_quotes_json_kwargs(self) -> None:
builder = CommandBuilder()
script_args = ["example", "-k", '{"kwarg": "value"}']
commands = builder.build_commands("celery -A app call", script_args)
expected = f"celery -A app call {_expected_script_args_str(script_args)}"
assert commands == [expected]
assert commands == [f"celery -A app call {_join_script_args(script_args)}"]

def test_build_command_quotes_args_with_spaces(self) -> None:
builder = CommandBuilder()
script_args = ["hello world"]
commands = builder.build_commands("echo", script_args)
expected = f"echo {_expected_script_args_str(script_args)}"
assert commands == [expected]
assert commands == [f"echo {_join_script_args(script_args)}"]


def _strip_carets(text: str) -> str:
result = []
index = 0
while index < len(text):
if text[index] == "^" and index + 1 < len(text):
index += 1
result.append(text[index])
index += 1
return "".join(result)


class TestCmdQuoting:
@pytest.mark.parametrize(
("arg", "expected"),
[
("plain", "plain"),
("foo&whoami", "foo^&whoami"),
("a|b", "a^|b"),
("a>out", "a^>out"),
("%USERPROFILE%", "^%USERPROFILE^%"),
("x^y", "x^^y"),
("a(b)", "a^(b^)"),
],
)
def test_metacharacters_are_escaped(self, arg: str, expected: str) -> None:
assert _quote_for_cmd(arg) == expected

def test_arg_with_spaces_is_quoted_and_escaped(self) -> None:
# list2cmdline supplies the quotes the child needs; the carets stop cmd.exe from
# acting on the metacharacter before the child ever sees it.
assert _quote_for_cmd("hello & world") == '^"hello ^& world^"'

@pytest.mark.parametrize("char", list('^&|<>()"%!'))
def test_every_metacharacter_is_escaped_reversibly(self, char: str) -> None:
arg = f"a{char}b"
quoted = _quote_for_cmd(arg)
assert f"^{char}" in quoted
assert _strip_carets(quoted) == list2cmdline([arg])


class TestCommandValidator:
Expand Down
Loading
Loading