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
26 changes: 14 additions & 12 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
version: 2
updates:
# Python dependencies (requirements.txt + requirements-dev.txt).
# Python dependencies: pyproject.toml ([project].dependencies and the
# dependency groups) plus uv.lock, which Dependabot re-locks in the same PR.
#
# This package publishes open `>=` ranges rather than a lockfile, so a
# Dependabot PR here raises the *floor* consumers are allowed to install on,
# not just the version CI happens to resolve. That is the whole point: the
# floor is the exposure, and the audit gate in security.yml scans it
# Consumers never see uv.lock -- this package publishes open `>=` ranges --
# so a Dependabot PR here raises the *floor* consumers are allowed to install
# on, not just the version CI happens to resolve. That is the whole point:
# the floor is the exposure, and the audit gate in security.yml scans it
# explicitly.
- package-ecosystem: "pip"
- package-ecosystem: "uv"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
# REQUIRED, not cosmetic. With a setup.py present Dependabot classifies
# this project as a library and defaults to `widen`, which only relaxes
# upper bounds and would never raise a `>=` floor -- so the automation
# would silently never do the one thing this file exists to do.
# `increase` raises the lower bound instead.
# REQUIRED, not cosmetic. Left at `auto`, Dependabot may classify this
# published package as a library and default to `widen`, which only
# relaxes upper bounds and would never raise a `>=` floor -- so the
# automation would silently never do the one thing this file exists to
# do. `increase` raises the lower bound instead (and moves the exact `==`
# pins in the dev group).
versioning-strategy: increase
# Matches the agent-security policy: wait 7 days before proposing a
# release, 14 for a major. A brand-new version is the window in which a
Expand Down Expand Up @@ -49,7 +51,7 @@ updates:

# GitHub Actions versions.
# Note: cooldown.semver-major-days is not supported for github-actions --
# Dependabot only honours it on semver-strict ecosystems like pip and npm.
# Dependabot only honours it on semver-strict ecosystems like uv and npm.
- package-ecosystem: "github-actions"
directory: "/"
schedule:
Expand Down
56 changes: 43 additions & 13 deletions .github/scripts/audit-deps.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,33 @@
# file literally named requirements.txt, plus one Trivy report per tree:
#
# runtime-ceiling/ + trivy-runtime-ceiling.json
# requirements.txt alone, current resolution. What a fresh
# `pip install permit` gets today.
# pyproject.toml [project].dependencies alone, current resolution. What
# a fresh `pip install permit` gets today.
# runtime-floor/ + trivy-runtime-floor.json
# requirements.txt alone, lowest-direct. The lowest versions the
# PUBLISHED specs permit -- i.e. real consumer exposure. This is the
# tree that matters most for a library with open `>=` ranges.
# pyproject.toml [project].dependencies alone, lowest-direct. The lowest
# versions the PUBLISHED specs permit -- i.e. real consumer exposure.
# This is the tree that matters most for a library with open `>=`
# ranges.
# dev-ceiling/ + trivy-dev-ceiling.json
# requirements.txt + requirements-dev.txt, current resolution. Test
# tooling only; never ships to a user.
# [project].dependencies + the `dev` dependency group, current
# resolution. Test tooling only; never ships to a user.
#
# These are compiled from pyproject.toml, NOT exported from uv.lock: the lock
# pins one resolution for this repo's own CI, while the audit has to see what a
# consumer can resolve from the published ranges -- today's ceiling and the
# floor.
#
# Plus pip-audit.json (advisory only) for the runtime ceiling.
#
# WHY RUNTIME IS COMPILED ALONE. Compiling the runtime and dev files together
# WHY RUNTIME IS COMPILED ALONE. Compiling the runtime and dev deps together
# lets a dev tool drag a runtime dependency's floor upward and hide the real
# exposure: with mypy in the mix the floor resolves typing-extensions==4.12.0,
# because mypy requires >=4.6 -- but a consumer installing only `permit` can
# still land on 4.5.0. Scanning the combined floor would silently under-report
# exactly the versions users can actually get.
#
# WHY COMPILE AT ALL. Trivy's pip analyzer only understands `==`. Pointed at
# this repo's raw requirements.txt it reports zero findings and exits 0 -- a
# a list of open ranges it reports zero findings and exits 0 -- a
# silently green gate. It also keys on the FILENAME, which is why each tree is
# written to its own directory as `requirements.txt` rather than scanned as a
# loose file (a loose file reports "Not scanned" and, again, exits 0).
Expand All @@ -50,7 +56,17 @@ compile_tree() {
local name="$1" resolution="$2"
shift 2
mkdir -p "${OUT}/${name}"
local args=(--python-version "${PYTHON_VERSION}" --quiet -o "${OUT}/${name}/requirements.txt")
# --no-sources: the published build ignores [tool.uv.sources] (uv build
# --no-sources), so the audit must too. --exclude-newer false: the publish-age
# cooldown in pyproject.toml applies to this repo's `uv lock` only; consumers
# resolve against the index as it is today.
local args=(
--no-sources
--exclude-newer false
--python-version "${PYTHON_VERSION}"
--quiet
-o "${OUT}/${name}/requirements.txt"
)
if [ -n "${resolution}" ]; then
args+=(--resolution "${resolution}")
fi
Expand All @@ -71,9 +87,23 @@ echo "::group::Resolving dependency trees (python ${PYTHON_VERSION})"
# lowest-direct, not lowest: pin the declared bounds to their floor but let
# transitives resolve normally. Plain `lowest` would drag every transitive back
# to its first ever release and drown the report in irrelevant history.
compile_tree runtime-ceiling "" "${REPO_ROOT}/requirements.txt"
compile_tree runtime-floor "lowest-direct" "${REPO_ROOT}/requirements.txt"
compile_tree dev-ceiling "" "${REPO_ROOT}/requirements.txt" "${REPO_ROOT}/requirements-dev.txt"
# Passing pyproject.toml compiles [project].dependencies only; dependency
# groups are added solely by an explicit --group.
compile_tree runtime-ceiling "" "${REPO_ROOT}/pyproject.toml"
compile_tree runtime-floor "lowest-direct" "${REPO_ROOT}/pyproject.toml"
compile_tree dev-ceiling "" "${REPO_ROOT}/pyproject.toml" \
--group "${REPO_ROOT}/pyproject.toml:dev"

# The package-count check above cannot tell a dev tree from a runtime one, so a
# --group that silently matched nothing would scan the runtime tree twice and
# report the dev tooling as clean.
if ! grep -q '^pytest==' "${OUT}/dev-ceiling/requirements.txt"; then
message="Tree 'dev-ceiling' does not contain pytest, so the 'dev' dependency group"
message+=" was not resolved. Refusing to scan a runtime-only tree and report the dev"
message+=" tooling as clean."
echo "::error title=Dependency resolution failed::${message}"
exit 1
fi
echo "::endgroup::"

# Trivy exits non-zero on findings when --exit-code is set. We do not set it:
Expand Down
5 changes: 3 additions & 2 deletions .github/scripts/format_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,8 +422,9 @@ def render(
out.append("### How to fix")
out.append("")
out.append(
"Raise the affected lower bound in `requirements.txt` (or `requirements-dev.txt`) "
"to at least the *Fixed in* version above. Because this package publishes open "
"Raise the affected lower bound in `pyproject.toml` (`[project].dependencies`, or the "
"pin in the `dev` dependency group) to at least the *Fixed in* version above, then "
"run `uv lock`. Because this package publishes open "
"`>=` ranges, the floor is what consumers can actually install -- bumping only the "
"resolved version does not close the hole."
)
Expand Down
25 changes: 23 additions & 2 deletions .github/workflows/pre-commit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,28 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0

- name: Install uv
id: setup-uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
python-version: "3.11"
- uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
enable-cache: true

# Hook environments, keyed on the config that defines them and on the
# Python they were built with, since a hook venv does not survive an
# interpreter change. This is the cache pre-commit/action used to provide.
- name: Cache pre-commit hook environments
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/pre-commit
key: >-
pre-commit-${{ runner.os }}-py${{ steps.setup-uv.outputs.python-version }}-${{
hashFiles('.pre-commit-config.yaml') }}

# pre-commit itself comes from the locked dev group; --only-dev skips
# installing the project, which no hook needs.
- name: Run pre-commit
run: >-
uv run --locked --only-dev
pre-commit run --all-files --show-diff-on-failure --color=always
40 changes: 18 additions & 22 deletions .github/workflows/python-sdk-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,19 @@ jobs:
with:
persist-credentials: false

- name: Python setup
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
python-version: ${{ env.PYTHON_VERSION }}
# This uv binary is the build backend for the published artifacts,
# so pin its archive (uv-x86_64-unknown-linux-gnu.tar.gz, per the
# uv release's .sha256 asset). The version comes from [tool.uv]
# required-version in pyproject.toml; bumping that without updating
# this hash fails the download instead of building with another uv.
checksum: "89eadd7c76fc063887959510d5ba0ab1264dfd5f1143b925ddb73021a40acf16"
# No cache on a job whose output is published: a poisoned cache
# entry would flow straight into the release artifacts.
enable-cache: false

# The release tag is attacker-influenceable text, so it is passed through
# the environment rather than interpolated into the shell body. zizmor
Expand All @@ -42,35 +51,22 @@ jobs:
run: |
set -euo pipefail
# Strip a leading v and validate, so a crafted tag cannot smuggle
# anything into setup.py.
# anything into pyproject.toml.
version="${RELEASE_TAG#v}"
# A whole-string bash match, NOT grep: grep is line-oriented, so a
# multi-line tag would pass on the strength of its first line and
# the remainder would still reach setup.py.
# the remainder would still reach pyproject.toml.
if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([a-z0-9.]*)$ ]]; then
echo "::error title=Invalid release tag::'${RELEASE_TAG}' is not a valid PEP 440 version."
exit 1
fi
python - "$version" <<'PY'
import pathlib
import re
import sys

version = sys.argv[1]
path = pathlib.Path("setup.py")
source = path.read_text()
patched, count = re.subn(r'version="[^"]*"', f'version="{version}"', source, count=1)
if count != 1:
sys.exit("could not find a version= field to patch in setup.py")
path.write_text(patched)
print(f"setup.py version set to {version}")
PY
# --frozen: rewrite [project].version only. Re-locking here would
# resolve against the live index during a release build.
uv version --frozen "${version}"

# --no-sources: build as a consumer's resolver sees it.
- name: Build Python package
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build
python -m build
run: uv build --no-sources

- name: Upload distribution
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
Expand Down
17 changes: 7 additions & 10 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,6 @@ on:
push:
branches: [main, master]
paths:
- "requirements.txt"
- "requirements-dev.txt"
- "setup.py"
- "pyproject.toml"
- ".github/workflows/security.yml"
- ".github/scripts/audit-deps.sh"
Expand Down Expand Up @@ -108,7 +105,7 @@ jobs:
"runtime-floor=/tmp/audit/trivy-runtime-floor.json" \
"dev-ceiling=/tmp/audit/trivy-dev-ceiling.json" \
--pip-audit /tmp/audit/pip-audit.json \
--context "requirements.txt + requirements-dev.txt, resolved at Python 3.10 (both the current resolution and the lowest versions the published specs permit)" \
--context "pyproject.toml dependencies + dev group, resolved at Python 3.10 (both the current resolution and the lowest versions the published specs permit)" \
--blocking \
> /tmp/audit/comment.md 2>/tmp/audit/format.err
render_exit=$?
Expand Down Expand Up @@ -265,16 +262,16 @@ jobs:
with:
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
python-version: ${{ env.PYTHON_VERSION }}
enable-cache: true

- name: Install pytest
run: python -m pip install --disable-pip-version-check pytest

# pytest from the locked dev group; the scripts under test are stdlib
# only, so the project itself is not installed.
- name: Run audit script tests
run: python -m pytest .github/scripts/test_format_audit.py -q
run: uv run --locked --only-dev pytest .github/scripts/test_format_audit.py -q

- name: Shellcheck the audit script
run: shellcheck .github/scripts/audit-deps.sh
Expand Down
60 changes: 44 additions & 16 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ jobs:
fail-fast: false
matrix:
pydantic-version: ['pydantic<2.0.0', 'pydantic>=2.0.0']
# `include` only attaches extra keys to the two existing entries; it
# adds no new jobs and leaves the job names below untouched. Each
# lane installs the matching dependency group from pyproject.toml, so
# both pydantic resolutions come from uv.lock.
include:
- pydantic-version: 'pydantic<2.0.0'
dependency-group: pydantic-v1
pydantic-major: '1'
- pydantic-version: 'pydantic>=2.0.0'
dependency-group: pydantic-v2
pydantic-major: '2'
# NOTE: this name and the matrix shape are load-bearing. Branch protection
# on main requires the contexts "pytest (Pydantic pydantic<2.0.0)" and
# "pytest (Pydantic pydantic>=2.0.0)" by exact string. Renaming the job or
Expand All @@ -37,10 +48,16 @@ jobs:
with:
persist-credentials: false

- name: Python setup
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
python-version: '3.11.8'
# Sets UV_PYTHON, so every uv command below runs on exactly this
# interpreter (downloaded by uv if the runner does not have it).
python-version: "3.11.8"
enable-cache: true
# The two lanes share uv.lock but install different pydantic
# majors, so they need separate cache entries.
cache-suffix: ${{ matrix.dependency-group }}

# Values reach the shell through env: rather than ${{ }} interpolation
# into the script body. zizmor flags the interpolated form as
Expand Down Expand Up @@ -111,22 +128,34 @@ jobs:
permitio/pdp-v2:latest
echo "PDP container started; it warms up while dependencies install."

# --locked fails the job if uv.lock is out of date with pyproject.toml
# instead of silently re-resolving.
- name: Install dependencies
env:
PYDANTIC_VERSION: ${{ matrix.pydantic-version }}
DEPENDENCY_GROUP: ${{ matrix.dependency-group }}
run: uv sync --locked --group "${DEPENDENCY_GROUP}"

- name: Show installed packages
run: uv pip list

# Proves the lane runs the pydantic major its name claims. Without it a
# resolution change could quietly run both lanes on the same major.
- name: Verify pydantic major
env:
EXPECTED_MAJOR: ${{ matrix.pydantic-major }}
run: |
set -euo pipefail
python -m pip install --upgrade pip
pip install pytest
# Pin pydantic version according to matrix
pip install "${PYDANTIC_VERSION}"
# Explicitly install email-validator which is required for Pydantic email validation
pip install email-validator
if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi
if [ -f requirements.txt ]; then pip install -r requirements.txt --no-deps; fi
uv run --no-sync python - <<'PY'
import os
import sys

- name: Show installed packages
run: pip list
import pydantic

expected = os.environ["EXPECTED_MAJOR"]
print(f"python {sys.version.split()[0]}, pydantic {pydantic.VERSION}")
if str(pydantic.VERSION).split(".")[0] != expected:
sys.exit(f"expected pydantic {expected}.x, got {pydantic.VERSION}")
PY

# Waited for here rather than immediately after `docker run`, so the
# PDP's bootstrap (fetch config, pull the policy bundle, start OPA)
Expand Down Expand Up @@ -156,8 +185,7 @@ jobs:
ORG_PDP_API_KEY: ${{ env.ENV_API_KEY }}
PROJECT_PDP_API_KEY: ${{ env.ENV_API_KEY }}
PDP_API_KEY: ${{ env.ENV_API_KEY }}
run: |
pytest -s --cache-clear tests/
run: uv run --no-sync pytest -s --cache-clear tests/

- name: PDP logs
if: failure()
Expand Down
3 changes: 0 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,6 @@ target/
profile_default/
ipython_config.py

# pyenv
.python-version

# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
Expand Down
Loading
Loading