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
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ repos:
args: []
additional_dependencies:
- click
- json5>=0.15.0
- markdown-it-py
- pytest
- nox
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,10 @@ for family, grp in itertools.groupby(collected.checks.items(), key=lambda x: x[1
- [`PP308`](https://learn.scientific-python.org/development/guides/pytest#PP308): Specifies useful pytest summary
- [`PP309`](https://learn.scientific-python.org/development/guides/pytest#PP309): Filter warnings specified

### dependencies

- [`DEP200`](https://learn.scientific-python.org/development/guides/gha-basic#DEP200): Maintained by Dependabot or Renovate.

### GitHub Actions

- [`GH100`](https://learn.scientific-python.org/development/guides/gha-basic#GH100): Has GitHub Actions config
Expand Down Expand Up @@ -400,6 +404,11 @@ Will not show up if using lefthook instead of pre-commit/prek.
- [`PC902`](https://learn.scientific-python.org/development/guides/style#PC902): Custom pre-commit CI autofix message
- [`PC903`](https://learn.scientific-python.org/development/guides/style#PC903): Specified pre-commit CI schedule

### Renovate

- [`REN200`](https://learn.scientific-python.org/development/guides/gha-basic#REN200): Maintained by Renovate
- [`REN210`](https://learn.scientific-python.org/development/guides/gha-basic#REN210): Maintains the GitHub action versions with Renovate

### ReadTheDocs

Will not show up if no `.readthedocs.yml`/`.readthedocs.yaml` file is present.
Expand Down
21 changes: 20 additions & 1 deletion docs/guides/gha_basic.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,8 @@ static. And old versioned images are decommissioned.

## Updating

{rr}`GH200` {rr}`GH210` If you use non-default actions in your repository
{rr}`DEP200` {rr}`GH200` {rr}`GH210`
If you use non-default actions in your repository
(you will see some in the following pages), then it's a good idea to keep them
up to date. GitHub provided a way to do this with dependabot. Just add the
following file as `.github/dependabot.yml`:
Expand Down Expand Up @@ -189,6 +190,24 @@ which is both cleaner and sometimes required for dependent actions, like

You can use this for other ecosystems too, including Python.

{rr}`REN200` {rr}`REN210` [Renovate](https://docs.renovatebot.com/) can also be
used for keeping GitHub Actions (and other ecosystems) up to date as well. A
good starting point for `renovate.json` with the
[hosted version](https://docs.renovatebot.com/getting-started/installing-onboarding/)
which will cover GitHub Actions and most other ecosystems is:

```json
{
"extends": ["config:recommended"]
}
```

:::{tip}
Most Renovate `.jsonc` or `.json5` configs can be parsed without extra
dependencies, but include the `json5` optional dependency in order to load the
full range of the formats.
:::

## Common needs

### Single OS steps
Expand Down
9 changes: 8 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ async = [
"repo-review[async]",
]
all = [
"sp-repo-review[cli,pyproject,async]",
"sp-repo-review[cli,pyproject,async,json5]",
]
json5 = [
"json5>=0.15.0",
]

[project.urls]
Expand All @@ -62,6 +65,7 @@ sp-repo-review = "repo_review.__main__:main"
sp-ruff-checks = "sp_repo_review.ruff_checks.__main__:main"

[project.entry-points."repo_review.checks"]
dependencies = "sp_repo_review.checks.dependencies:repo_review_checks"
general = "sp_repo_review.checks.general:repo_review_checks"
pyproject = "sp_repo_review.checks.pyproject:repo_review_checks"
precommit = "sp_repo_review.checks.precommit:repo_review_checks"
Expand All @@ -70,6 +74,7 @@ mypy = "sp_repo_review.checks.mypy:repo_review_checks"
github = "sp_repo_review.checks.github:repo_review_checks"
security = "sp_repo_review.checks.security:repo_review_checks"
readthedocs = "sp_repo_review.checks.readthedocs:repo_review_checks"
renovate = "sp_repo_review.checks.renovate:repo_review_checks"
setupcfg = "sp_repo_review.checks.setupcfg:repo_review_checks"
noxfile = "sp_repo_review.checks.noxfile:repo_review_checks"

Expand All @@ -79,6 +84,7 @@ noxfile = "sp_repo_review.checks.noxfile:noxfile"
precommit = "sp_repo_review.checks.precommit:precommit"
pytest = "sp_repo_review.checks.pyproject:pytest"
readthedocs = "sp_repo_review.checks.readthedocs:readthedocs"
renovate = "sp_repo_review.checks.renovate:renovate"
ruff = "sp_repo_review.checks.ruff:ruff"
setupcfg = "sp_repo_review.checks.setupcfg:setupcfg"
workflows = "sp_repo_review.checks.github:workflows"
Expand All @@ -101,6 +107,7 @@ dev = [
test = [
"pytest >=9",
"repo-review >=0.10.6",
"sp-repo-review[json5]"
]
cog = [
"cogapp",
Expand Down
91 changes: 91 additions & 0 deletions src/sp_repo_review/checks/_jsonc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Minimal, string-aware JSONC preprocessing.

Strips ``//`` and ``/* */`` comments and trailing commas so that JSONC (and the
subset of JSON5 that only uses those features) can be parsed by the standard
library :mod:`json`. Full JSON5 (single-quoted strings, unquoted keys, hex
numbers, etc.) is *not* handled here and requires the optional ``json5``
dependency.
"""

from __future__ import annotations

_WHITESPACE = " \t\r\n"


def _copy_string(text: str, i: int, out: list[str]) -> int:
"""Copy a double-quoted string verbatim, returning the index after it."""
length = len(text)
out.append(text[i]) # opening quote
i += 1
while i < length:
char = text[i]
out.append(char)
if char == "\\" and i + 1 < length:
# Copy the escaped character so an escaped quote does not
# prematurely close the string.
out.append(text[i + 1])
i += 2
elif char == '"':
i += 1
break
else:
i += 1
return i


def _skip_line_comment(text: str, i: int) -> int:
"""Skip a ``// ...`` comment, returning the index of the line ending."""
i += 2
length = len(text)
while i < length and text[i] not in "\r\n":
i += 1
return i


def _skip_block_comment(text: str, i: int) -> int:
"""Skip a ``/* ... */`` comment, returning the index after it."""
i += 2
length = len(text)
while i + 1 < length and not (text[i] == "*" and text[i + 1] == "/"):
i += 1
return i + 2 # Past the end is harmless; the caller re-checks bounds.


def _drop_trailing_comma(out: list[str]) -> None:
"""Remove a trailing comma before a closing bracket, ignoring whitespace."""
last = len(out) - 1
while last >= 0 and out[last] in _WHITESPACE:
last -= 1
if last >= 0 and out[last] == ",":
del out[last]


def strip_jsonc(text: str) -> str:
"""Remove comments and trailing commas from JSONC ``text``.

The scan is string-aware: characters inside double-quoted strings (and their
backslash escapes) are copied verbatim, so comment markers or commas that
appear inside string values are left untouched. The function is total and
never raises; validating the result is left to the caller's ``json.loads``.
"""
out: list[str] = []
i = 0
length = len(text)

while i < length:
char = text[i]
if char == '"':
i = _copy_string(text, i, out)
elif char == "/" and text.startswith("//", i):
i = _skip_line_comment(text, i)
elif char == "/" and text.startswith("/*", i):
i = _skip_block_comment(text, i)
elif char in "}]":
_drop_trailing_comma(out)
out.append(char)
i += 1
else:
out.append(char)
i += 1

return "".join(out)
52 changes: 52 additions & 0 deletions src/sp_repo_review/checks/dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# DU: Dependency Updating

from __future__ import annotations

from typing import Any

from . import mk_url


class Dependencies:
family = "dependencies"


class DEP200(Dependencies):
"""Maintained by Dependabot or Renovate."""

url = mk_url("gha-basic")

@staticmethod
def check(dependabot: dict[str, Any], renovate: dict[str, Any]) -> bool:
"""
All projects should have a tool to manage dependencies, either Dependabot or Renovate.

Something like one of these:

`.github/dependabot.yml`
```yaml
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
```

`renovate.json`
```json
{{
"extends": ["config:recommended"]
}}
```
Renovate configurations in `package.json` are not supported.
`.jsonc` files (and `.json5` files that only use comments or trailing
commas) are supported out of the box. Full JSON5 configs require the
optional `json5` dependency (`pip install sp-repo-review[json5]`).
"""
return bool(dependabot or renovate)


def repo_review_checks() -> dict[str, Dependencies]:
return {p.__name__: p() for p in Dependencies.__subclasses__()}
4 changes: 3 additions & 1 deletion src/sp_repo_review/checks/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ class GH200(GitHub):
url = mk_url("gha-basic")

@staticmethod
def check(dependabot: dict[str, Any]) -> bool:
def check(dependabot: dict[str, Any]) -> bool | None:
"""
All projects should have a `.github/dependabot.yml` file to support at least
GitHub Actions regular updates. Something like this:
Expand All @@ -214,6 +214,8 @@ def check(dependabot: dict[str, Any]) -> bool:
interval: "weekly"
```
"""
if not dependabot:
return None
return bool(dependabot)


Expand Down
125 changes: 125 additions & 0 deletions src/sp_repo_review/checks/renovate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# REN: Renovate Actions

from __future__ import annotations

__lazy_modules__ = ["json"]

import json
from typing import TYPE_CHECKING, Any

from . import mk_url
from ._jsonc import strip_jsonc

if TYPE_CHECKING:
from .._compat.importlib.resources.abc import Traversable


SUPPORTED_RENOVATE_FILES = [
"renovate.json",
"renovate.jsonc",
"renovate.json5",
".github/renovate.json",
".github/renovate.jsonc",
".github/renovate.json5",
".gitlab/renovate.json",
".gitlab/renovate.jsonc",
".gitlab/renovate.json5",
".renovaterc",
".renovaterc.json",
".renovaterc.jsonc",
".renovaterc.json5",
# "package.json" # Deprecated by Renovate
]


def renovate(root: Traversable) -> dict[str, Any]:
renovate_paths = [root.joinpath(f) for f in SUPPORTED_RENOVATE_FILES]

for renovate_path in renovate_paths:
if not renovate_path.is_file():
continue
with renovate_path.open(encoding="utf-8") as f:
text = f.read()
try:
# Handles JSON and JSONC (comments / trailing commas) with no
# dependency, plus JSON5 files that use only those features.
result: dict[str, Any] = json.loads(strip_jsonc(text))
except json.JSONDecodeError:
# Full JSON5 (single quotes, unquoted keys, ...) needs a real
# parser, provided by the optional json5 extra.
try:
import json5 # noqa: PLC0415
except ImportError:
msg = (
f"{renovate_path} could not be parsed as JSON/JSONC and needs "
"full JSON5 support. Install the extra: "
"pip install sp-repo-review[json5]"
)
raise ImportError(msg) from None
try:
result = json5.loads(text)
except ValueError:
continue
return result
return {}


class Renovate:
family = "renovate"


class REN200(Renovate):
"""Maintained by Renovate"""

requires = {"DEP200"}
url = mk_url("gha-basic")

@staticmethod
def check(renovate: dict[str, Any]) -> bool | None:
"""
All projects should have a renovate configuration file (`renovate.json` or other supported locations)
to support dependency updates. Something like this:

```json
{{
"extends": ["config:recommended"]
}}
```

Renovate configurations in `package.json` are not supported.
`.jsonc` files (and `.json5` files that only use comments or trailing
commas) are supported out of the box. Full JSON5 configs require the
optional `json5` dependency (`pip install sp-repo-review[json5]`).
"""
if not renovate:
return None
return bool(renovate)


GHA_EXTENDS = {"config:recommended", "config:best-practices"}


class REN210(Renovate):
"""Maintains the GitHub action versions with Renovate"""

requires = {"REN200"}
url = mk_url("gha-basic")

@staticmethod
def check(renovate: dict[str, Any]) -> bool | str | None:
"""
Ensures that Renovate is configured to maintain GitHub action versions.

Checks for if the `github-actions` manager is enabled or if the Renovate config extends a known config (`config:recommended` or `config:best-practices`).
"""
if (manager := renovate.get("github-actions", {})) and manager.get("enabled"):
return True
if extends := renovate.get("extends", []):
if any(e in GHA_EXTENDS for e in extends):
return True
return f"Renovate config extends {extends}, but none are a known config: {', '.join(GHA_EXTENDS)}."
return False


def repo_review_checks() -> dict[str, Renovate]:
return {p.__name__: p() for p in Renovate.__subclasses__()}
Loading
Loading