Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 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
119 changes: 119 additions & 0 deletions .github/actions/setup-uv-python/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Copyright 2026 OpenC3, Inc.
# All Rights Reserved.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
# See LICENSE.md for more details.
#
# This file may also be used under the terms of a commercial license
# if purchased from OpenC3, Inc.

name: Setup uv and Python
description: |
Install uv, provision the Python interpreter, and sync openc3/python's
locked dependencies.

This exists to keep one uv version across every workflow. Before it, the
five workflows that use uv ran three different versions -- 0.10.4, 0.12.5,
and "whatever released most recently" for the three that pinned nothing --
and three of them omitted setup-uv's working-directory, so cache discovery
ran from the repo root where there is no uv.lock and produced a weaker key.

The uv version lives in the uv-version default below. That is the single
place to bump it, and .github/workflows/tool_version_check.yml watches it
against upstream because dependabot cannot see versions inside an Actions
input.

inputs:
uv-version:
description: |
uv release to install. The default is the version this repository
standardizes on; override only to test a candidate.
required: false
default: "0.12.5"
working-directory:
description: |
Directory holding pyproject.toml and uv.lock. Also given to setup-uv so
the dependency cache is keyed on that uv.lock.
required: false
default: openc3/python
python-version:
description: |
Python to install, for matrix builds. Empty means use the version in
.python-version.
required: false
default: ""
cache-suffix:
description: Appended to the cache key, so matrix legs do not share a cache.
required: false
default: ""
sync:
description: |
Whether to run uv sync. Set false when the workflow needs other setup
(a gem build, say) to happen between installing uv and syncing.
required: false
default: "true"
no-install-project:
description: |
Skip the editable install of openc3 itself. Set true for jobs that only
read the source tree, which also lets --no-build apply, since that
editable install is the only thing here that requires a build.
required: false
default: "false"
no-build:
description: |
Refuse to build any source distribution, so no third-party build backend
executes in CI. Requires no-install-project.
required: false
default: "false"

runs:
using: composite
steps:
- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
version: ${{ inputs.uv-version }}
enable-cache: true
cache-suffix: ${{ inputs.cache-suffix }}
python-version: ${{ inputs.python-version }}
working-directory: ${{ inputs.working-directory }}

- name: Set up Python
shell: bash
working-directory: ${{ inputs.working-directory }}
env:
PYTHON_VERSION: ${{ inputs.python-version }}
# With no python-version this reads .python-version; with one it installs
# exactly that. setup-uv has already exported UV_PYTHON in the latter case
run: uv python install $PYTHON_VERSION

# --frozen is not optional: it is what makes the install reproducible, so
# it is hardcoded rather than exposed as a caller-supplied argument string
# that could omit it or add --upgrade. The remaining flags are booleans for
# the same reason -- nothing here interpolates into the shell.
- name: Install dependencies
if: ${{ inputs.sync == 'true' }}
shell: bash
working-directory: ${{ inputs.working-directory }}
env:
NO_INSTALL_PROJECT: ${{ inputs.no-install-project }}
NO_BUILD: ${{ inputs.no-build }}
run: |
args=(--frozen)
if [ "$NO_INSTALL_PROJECT" = "true" ]; then
args+=(--no-install-project)
fi
if [ "$NO_BUILD" = "true" ]; then
if [ "$NO_INSTALL_PROJECT" != "true" ]; then
# openc3 is an editable install, so it is the one distribution here
# that has to be built; --no-build without --no-install-project
# fails deep inside uv with a confusing resolver error
echo "::error::no-build requires no-install-project" >&2
exit 1
fi
args+=(--no-build)
fi
echo "uv sync ${args[*]}"
uv sync "${args[@]}"

Check warning on line 119 in .github/actions/setup-uv-python/action.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=OpenC3_cosmos&issues=AaAlIk1_vQPUgjAWdYnx&open=AaAlIk1_vQPUgjAWdYnx&pullRequest=3752
24 changes: 24 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ updates:
schedule:
interval: weekly
groups:
# Majors get their own pull request. Excluding them entirely is how
# setup-uv silently sat on v8 while upstream reached v10, but bundling
# them with the routine bumps is worse: one group is one PR, so a single
# breaking upgrade among ~27 actions blocks all of them, and Actions
# treats a renamed input as a warning rather than an error, so a green
# run does not prove a major was harmless.
#
# Order matters here: a dependency joins the first group it matches.
github-actions-major:
patterns:
- "*"
update-types:
- major
github-actions:
patterns:
- "*"
Expand All @@ -34,6 +47,17 @@ updates:
ignore:
- dependency-name: openc3
groups:
# Majors deliberately excluded. This pattern covers the runtime
# dependencies of the published openc3 package, every one of which has a
# deliberate upper cap (boto3 <2, lxml <7, psycopg <4, valkey <7, ...).
# A major bump means raising that cap, which changes what downstream
# users resolve, so it should be a maintainer decision rather than a
# weekly automated PR.
#
# ruff and ty lose nothing here: both are pre-1.0, so their breaking
# releases arrive as minors today. .github/workflows/tool_version_check.yml
# watches them, uv and mypy against upstream and files a tracking issue,
# which is the notification this exclusion would otherwise cost.
uv:
patterns:
- "*"
Expand Down
237 changes: 237 additions & 0 deletions .github/scripts/check_tool_versions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = ["requests==2.34.2"]
# ///

# Copyright 2026 OpenC3, Inc.
# All Rights Reserved.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
# See LICENSE.md for more details.
#
# This file may also be used under the terms of a commercial license
# if purchased from OpenC3, Inc.

"""Report pinned tool versions that have fallen behind upstream.

Dependabot covers most of this repository, but three kinds of pin are invisible
to it, and those are exactly the ones this script watches:

* versions inside a GitHub Actions input (setup-uv's `version`) or a
Dockerfile ARG consumed by `pip install` -- dependabot parses neither
* major releases, because every group in .github/dependabot.yml is limited to
`update-types: [minor, patch]`
* PEP 723 script lockfiles (openc3/python/tools/*.py.lock)

Prints a markdown report on stdout and exits 1 when anything is behind, so a
scheduled workflow can turn that into a tracking issue.

uv run --script --locked .github/scripts/check_tool_versions.py
uv run --script --locked .github/scripts/check_tool_versions.py --quiet
"""

import argparse
import os
import re
import sys
from pathlib import Path
from typing import NamedTuple

import requests


REPO_ROOT = Path(__file__).resolve().parents[2]
TIMEOUT = 30

# Upstream sources. "pypi:<name>" looks up the newest release on PyPI;
# "gh:<owner>/<repo>" uses the latest GitHub release tag.
SETUP_UV = "gh:astral-sh/setup-uv"
UV = "pypi:uv"
RUFF = "pypi:ruff"
TY = "pypi:ty"
MYPY = "pypi:mypy"
REQUESTS = "pypi:requests"


class Surface(NamedTuple):
"""A pinned version to watch."""

label: str
path: str # repository-relative file holding the pin
pattern: str # regex whose first group captures the pinned version
source: str # upstream to compare against


SURFACES = [
Surface(
"setup-uv action",
".github/actions/setup-uv-python/action.yml",
r"astral-sh/setup-uv@[0-9a-f]{40} # v(\S+)",
SETUP_UV,
),
Surface(
"uv (all CI workflows)",
".github/actions/setup-uv-python/action.yml",
r'default:\s*"([\d.]+)"',
UV,
),
Surface(
"uv (openc3-ruby image)",
"openc3-ruby/Dockerfile",
r"ARG UV_VERSION=([\d.]+)",
UV,
),
Surface(
"uv (openc3-ruby ubi image)",
"openc3-ruby/Dockerfile-ubi",
r"ARG UV_VERSION=([\d.]+)",
UV,
),
Surface(
"ruff (python dev dependency)",
"openc3/python/pyproject.toml",
r'"ruff==([\d.]+)"',
RUFF,
),
Surface(
"ty (python dev dependency)",
"openc3/python/pyproject.toml",
r'"ty==([\d.]+)"',
TY,
),
Surface(
"mypy (stub generator script)",
"openc3/python/tools/generate_singleton_stubs.py",
r'"mypy==([\d.]+)"',
MYPY,
),
Surface(
"requests (this script)",
".github/scripts/check_tool_versions.py",
r'"requests==([\d.]+)"',
REQUESTS,
),
Surface(
"ruff (stub generator script)",
"openc3/python/tools/generate_singleton_stubs.py",
r'"ruff==([\d.]+)"',
RUFF,
),
]


def fetch_json(url: str) -> dict:
headers = {"User-Agent": "openc3-tool-version-check"}
# GitHub's unauthenticated rate limit is low; use the workflow token if present
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
if token and "api.github.com" in url:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
headers["Authorization"] = f"Bearer {token}"
response = requests.get(url, headers=headers, timeout=TIMEOUT)
response.raise_for_status()
return response.json()


def latest_version(source: str, cache: dict) -> str | None:
if source in cache:
return cache[source]
kind, name = source.split(":", 1)
try:
if kind == "pypi":
version = fetch_json(f"https://pypi.org/pypi/{name}/json")["info"]["version"]
else:
version = fetch_json(f"https://api.github.com/repos/{name}/releases/latest")["tag_name"]
except (requests.RequestException, KeyError, ValueError) as error:
print(f"warning: could not resolve latest for {source}: {error}", file=sys.stderr)
version = None
cache[source] = version
return version


def as_tuple(version: str) -> tuple[int, ...]:
"""Numeric prefix of a version, for ordering. 'v10.0.1' -> (10, 0, 1)."""
return tuple(int(part) for part in re.findall(r"\d+", version)) or (0,)


class Result(NamedTuple):
"""A surface with the versions resolved for it."""

surface: Surface
pinned: str
latest: str

@property
def stale(self) -> bool:
return as_tuple(self.pinned) < as_tuple(self.latest)

@property
def major(self) -> bool:
"""Stale across a major boundary, which dependabot may not raise at all."""
return self.stale and as_tuple(self.pinned)[0] != as_tuple(self.latest)[0]

@property
def status(self) -> str:
"""How this row is marked in the report."""
if self.major:
return "**major**"
return "behind" if self.stale else "ok"


def source_url(source: str) -> str:
kind, name = source.split(":", 1)
return f"https://pypi.org/project/{name}/" if kind == "pypi" else f"https://github.com/{name}/releases"


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--quiet", action="store_true", help="suppress the report, return the exit code only")
args = parser.parse_args()

cache: dict[str, str | None] = {}
results: list[Result] = []
unresolved: list[str] = []

for surface in SURFACES:
path = REPO_ROOT / surface.path
if not path.exists():
unresolved.append(f"{surface.label}: {surface.path} not found")
continue
match = re.search(surface.pattern, path.read_text())
if not match:
unresolved.append(f"{surface.label}: no version matched in {surface.path}")
continue

latest = latest_version(surface.source, cache)
if latest is None:
unresolved.append(f"{surface.label}: upstream lookup failed")
continue

results.append(Result(surface, match.group(1), latest))

behind = [result.surface.label for result in results if result.stale]

if not args.quiet:
print("## Pinned tool versions\n")
print("| tool | pinned | latest | file |")
print("| --- | --- | --- | --- |")
for result in results:
surface = result.surface
latest = f"[`{result.latest}`]({source_url(surface.source)})"
print(f"| {surface.label} | `{result.pinned}` | {latest} | `{surface.path}` | {result.status} |")
if unresolved:
print("\n### Could not check\n")
for note in unresolved:
print(f"- {note}")
if behind:
print(f"\n{len(behind)} pin(s) behind upstream: {', '.join(behind)}.")
print("\nDependabot does not raise these, so they need a manual bump.")
else:
print("\nAll watched pins are current.")

return 1 if behind else 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading