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
4 changes: 4 additions & 0 deletions .git-blame-ignore-revs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Revisions that only reformat or mechanically re-lint code.
# Configure once per clone:
# git config blame.ignoreRevsFile .git-blame-ignore-revs
# (GitHub applies this file automatically in its blame view.)
9 changes: 8 additions & 1 deletion .github/actions/setup-hatch/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,15 @@ description: >-
runs:
using: "composite"
steps:
# Kept in step with pyproject.toml: `hatch` matches the dev extra and
# `hatchling` matches [build-system].requires. hatchling is pinned here only
# for consistency -- `hatch build` resolves the backend in an isolated
# environment from build-system.requires, so that pin is what actually
# produces the wheel (the artifact records `Generator: hatchling 1.32.0`).
# Three different values for the same tool across three files was only ever
# going to mislead someone debugging a build.
- shell: bash
run: |
python -m pip install --upgrade pip
pip install "virtualenv<20.36"
pip install hatchling==1.27.0 hatch==1.14.0
pip install hatchling==1.32.0 hatch==1.18.0
48 changes: 48 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Lint

env:
PYTHON_VERSION: "3.12"

# Deliberately not path-filtered. Ruff finishes in well under a minute, and its
# trigger surface is every Python file in the repository -- including the ones
# outside the Unit Tests filters (.hooks/, benchmarks/, tests/e2e/). Running
# unconditionally also keeps this usable as a required status check: a
# path-filtered workflow reports as "not run" rather than "passed", which blocks
# any pull request that does not happen to touch the filtered paths.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: lint-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
ruff:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
persist-credentials: false
- name: 🐍 setup python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: 🛠️ install deps
run: |
python -m pip install --upgrade pip
pip install uv
uv sync --extra dev
# Same ruff version the pre-commit hook uses (pinned in pyproject.toml,
# locked in uv.lock), so a clean commit locally stays clean here.
- name: 🧹 ruff check
run: uv run ruff check
- name: 🎨 ruff format
run: uv run ruff format --check
12 changes: 12 additions & 0 deletions .github/workflows/pr-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,18 @@ jobs:
core.setOutput('head_sha', pullRequest.head.sha);

build:
# This job checks out and executes untrusted pull request code. Under
# workflow_dispatch it runs in the default branch's context, which means its
# Actions cache scope is main's. Do not add caching here -- no `actions/cache`
# step, and no `cache:` input on setup-python -- or a preview build of a
# malicious branch could plant an entry that every workflow on main then
# restores. CodeQL's actions/cache-poisoning/poisonable-step alerts point at
# these steps for exactly that reason; they are inert only while nothing in
# this job writes a cache.
#
# The privilege split is what keeps this safe: this job holds `contents: read`
# and no secrets, and publish-package (which holds `id-token: write`) never
# checks out code -- it only downloads the built artifact.
needs: context
runs-on: ubuntu-latest
timeout-minutes: 10
Expand Down
20 changes: 0 additions & 20 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,26 +67,6 @@ jobs:
uv export --no-hashes --no-emit-project --format requirements-txt > /tmp/req-audit.txt
uvx pip-audit --strict --progress-spinner off --disable-pip --no-deps -r /tmp/req-audit.txt

ruff:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
persist-credentials: false
- name: 🐍 setup python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: 🛠️ install deps
run: |
python -m pip install --upgrade pip
pip install uv
uv sync --extra dev
- name: 🧹 run ruff
run: uv run ruff check

unsupported-python-install:
runs-on: ubuntu-latest
timeout-minutes: 10
Expand Down
86 changes: 74 additions & 12 deletions .github/workflows/version-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ on:
- 'socketsecurity/**'
- 'pyproject.toml'
- 'uv.lock'
# Included so a change to the check itself is exercised by its own PR.
- '.github/workflows/version-check.yml'

permissions:
contents: read
Expand All @@ -32,26 +34,70 @@ jobs:
pip install packaging

# Get version from current PR
PR_VERSION=$(grep -o "__version__.*" socketsecurity/__init__.py | awk '{print $3}' | tr -d "'")
PR_VERSION=$(grep -o "__version__.*" socketsecurity/__init__.py | awk '{print $3}' | tr -d "\"'")
echo "PR_VERSION=$PR_VERSION" >> $GITHUB_ENV

# Get version from main branch
MAIN_VERSION=$(git show origin/main:socketsecurity/__init__.py | grep -o "__version__.*" | awk '{print $3}' | tr -d "'")
MAIN_VERSION=$(git show origin/main:socketsecurity/__init__.py | grep -o "__version__.*" | awk '{print $3}' | tr -d "\"'")
echo "MAIN_VERSION=$MAIN_VERSION" >> $GITHUB_ENV

export PR_VERSION
export MAIN_VERSION

# Compare against both main and latest published PyPI release.
# Only enforce a bump when the PR actually changes shipped content.
# This workflow sits in its own paths filter so edits to it are
# exercised, but a CI-only change ships nothing and must not be told
# to cut a release. The comparison still runs and reports either way.
if git diff --name-only origin/main...HEAD \
| grep -qE '^(socketsecurity/|pyproject\.toml$|uv\.lock$)'; then
PACKAGE_CHANGED=true
else
PACKAGE_CHANGED=false
fi
export PACKAGE_CHANGED

# Compare against the latest published PyPI release.
python3 <<'PY'
import json
import os
import tomllib
import urllib.request
from packaging import version

pr_ver = version.parse(os.environ["PR_VERSION"])
main_ver = version.parse(os.environ["MAIN_VERSION"])

enforced = os.environ["PACKAGE_CHANGED"] == "true"

def reject(message):
print(message)
if enforced:
raise SystemExit(1)
print(
"ℹ️ Not enforced: this PR changes no packaged files, "
"so it ships nothing that needs a new version."
)
raise SystemExit(0)

with open("pyproject.toml", "rb") as fh:
pyproject_ver = version.parse(tomllib.load(fh)["project"]["version"])

# The version is two hand-maintained literals with nothing deriving one
# from the other: pyproject.toml is what actually gets published, and
# socketsecurity/__init__.py is what the CLI reports as its User-Agent.
# Every comparison below reads only __init__.py, so bumping that alone
# would pass this job and then publish under the old number -- caught
# late, by twine rejecting an existing file, after the merge. Require
# the two to agree before comparing anything. (uv.lock carries a third
# copy, but uv derives it and `uv lock --locked` in python-tests
# already fails when it drifts.)
if pr_ver != pyproject_ver:
reject(
f"❌ Version mismatch inside the PR: pyproject.toml is "
f"{pyproject_ver}, socketsecurity/__init__.py is {pr_ver}. "
f"Bump both."
)

with urllib.request.urlopen("https://pypi.org/pypi/socketsecurity/json") as response:
pypi_data = json.load(response)

Expand All @@ -62,19 +108,35 @@ jobs:
published_versions.append(parsed)

pypi_ver = max(published_versions) if published_versions else version.parse("0.0.0")
required_floor = max(main_ver, pypi_ver)

if pr_ver <= required_floor:
# The only hard requirement is that the version is ahead of what is
# actually released. Treating main's version as a second floor breaks
# the legitimate case where several PRs share one unreleased release:
# the first bumps main to the new version and the rest ride it without
# bumping again, which is what keeps them under a single changelog
# header. Main is therefore only a floor when this PR moves the
# version -- a change to it must go forwards, never backwards.
if pr_ver <= pypi_ver:
reject(
f"❌ Version {pr_ver} is already published on PyPI "
f"(latest release: {pypi_ver}). Bump it."
)

if pr_ver < main_ver:
reject(
f"❌ Version moves backwards: main is {main_ver}, PR is {pr_ver}."
)

if pr_ver == main_ver:
print(
f"❌ Version must be greater than main and PyPI! "
f"✅ Riding main's unreleased {pr_ver} "
f"(latest PyPI release: {pypi_ver})."
)
else:
print(
f"✅ Version properly incremented. "
f"Main: {main_ver}, PyPI: {pypi_ver}, PR: {pr_ver}"
)
raise SystemExit(1)

print(
f"✅ Version properly incremented. "
f"Main: {main_ver}, PyPI: {pypi_ver}, PR: {pr_ver}"
)
PY

- name: Require uv.lock update when pyproject changes
Expand Down
17 changes: 14 additions & 3 deletions .hooks/sync_version.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
PYPI_PROD_API = "https://pypi.org/pypi/socketsecurity/json"
PYPI_TEST_API = "https://test.pypi.org/pypi/socketsecurity/json"


def read_version_from_init(path: pathlib.Path) -> str:
content = path.read_text()
match = VERSION_PATTERN.search(content)
Expand All @@ -24,6 +25,7 @@ def read_version_from_init(path: pathlib.Path) -> str:
sys.exit(1)
return match.group(1)


def read_version_from_git(path: str) -> str:
try:
output = subprocess.check_output(["git", "show", f"HEAD:{path}"], text=True)
Expand All @@ -34,13 +36,15 @@ def read_version_from_git(path: str) -> str:
except subprocess.CalledProcessError:
return None


def bump_patch_version(version: str) -> str:
if ".dev" in version:
version = version.split(".dev")[0]
parts = version.split(".")
parts[-1] = str(int(parts[-1]) + 1)
return ".".join(parts)


def parse_stable_version(version: str):
if not STABLE_VERSION_PATTERN.fullmatch(version):
return None
Expand Down Expand Up @@ -72,6 +76,7 @@ def fetch_latest_stable_pypi_version():
return None
return max(stable_versions)


def find_next_available_dev_version(base_version: str) -> str:
existing_versions = fetch_existing_versions(PYPI_TEST_API)
for i in range(1, 100):
Expand All @@ -94,12 +99,13 @@ def find_next_stable_patch_version(current_version: str) -> str:
next_parts = (base_parts[0], base_parts[1], base_parts[2] + 1)
return format_stable_version(next_parts)


def inject_version(version: str):
print(f"🔁 Updating version to: {version}")

# Update __init__.py
init_content = INIT_FILE.read_text()
new_init_content = VERSION_PATTERN.sub(f"__version__ = '{version}'", init_content)
new_init_content = VERSION_PATTERN.sub(f'__version__ = "{version}"', init_content)
INIT_FILE.write_text(new_init_content)

# Update pyproject.toml
Expand Down Expand Up @@ -190,16 +196,21 @@ def main():
inject_version(new_version)
uv_lock_changed = run_uv_lock()
lock_hint = " and uv.lock" if uv_lock_changed else ""
print(f"⚠️ Version {current_version} is already published on PyPI — auto-bumped to {new_version}. Please git add{lock_hint} + commit again.")
print(
f"⚠️ Version {current_version} is already published on PyPI — auto-bumped to {new_version}. Please git add{lock_hint} + commit again."
)
sys.exit(1)

uv_lock_changed = run_uv_lock()
if uv_lock_changed:
print("⚠️ Version already bumped, but uv.lock was out of date and has been updated. Please git add uv.lock + commit again.")
print(
"⚠️ Version already bumped, but uv.lock was out of date and has been updated. Please git add uv.lock + commit again."
)
sys.exit(1)

print("✅ Version already bumped and uv.lock is up to date — proceeding.")
sys.exit(0)


if __name__ == "__main__":
main()
27 changes: 26 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,29 @@ repos:
entry: python .hooks/sync_version.py
language: python
always_run: true
pass_filenames: false
pass_filenames: false

# Ruff runs out of the project environment rather than the upstream
# astral-sh/ruff-pre-commit mirror so its version is pinned in exactly one
# place: `ruff==0.16.4` under [project.optional-dependencies].dev, locked
# in uv.lock and used verbatim by the Lint workflow. Dependabot has no
# pre-commit ecosystem and will not touch a mirror's `rev:`, so a mirror
# would drift out of step with CI and produce the worst failure mode for a
# hook -- clean locally, red on the pull request.
#
# `--fix` applies only ruff's fixes marked safe. When it changes a file
# pre-commit aborts the commit and leaves the edit in the working tree, so
# nothing lands without being looked at.
- id: ruff-check
name: ruff check
entry: uv run --extra dev ruff check --force-exclude --fix
language: system
types_or: [python, pyi]
require_serial: true

- id: ruff-format
name: ruff format
entry: uv run --extra dev ruff format --force-exclude
language: system
types_or: [python, pyi]
require_serial: true
Loading