ci(release): enforce changed-scope Ruff gate
This commit is contained in:
@@ -31,7 +31,7 @@ jobs:
|
|||||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.sha }}
|
ref: ${{ github.sha }}
|
||||||
fetch-depth: 1
|
fetch-depth: 0
|
||||||
- run: python scripts/image_release.py validate-checkout --actual "$(git rev-parse HEAD)" --expected "${GITHUB_SHA}"
|
- run: python scripts/image_release.py validate-checkout --actual "$(git rev-parse HEAD)" --expected "${GITHUB_SHA}"
|
||||||
- run: python scripts/image_release.py validate-event --event "${GITHUB_EVENT_NAME}" --ref "${GITHUB_REF}" --commit "${GITHUB_SHA}" --project pyproject.toml
|
- run: python scripts/image_release.py validate-event --event "${GITHUB_EVENT_NAME}" --ref "${GITHUB_REF}" --commit "${GITHUB_SHA}" --project pyproject.toml
|
||||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
@@ -42,14 +42,10 @@ jobs:
|
|||||||
- run: uv run pytest -q
|
- run: uv run pytest -q
|
||||||
- name: Honest scoped lint
|
- name: Honest scoped lint
|
||||||
run: uv run ruff check --select F,I app/db/migrations.py scripts/image_release.py scripts/ruff_ratchet.py tests/release
|
run: uv run ruff check --select F,I app/db/migrations.py scripts/image_release.py scripts/ruff_ratchet.py tests/release
|
||||||
- name: Reject increases over the v0.2.1 Ruff debt baseline
|
- name: Reject Ruff findings introduced by v0.3.0 or its patch
|
||||||
run: |
|
run: |
|
||||||
set +e
|
uv run python scripts/ruff_ratchet.py --base v0.2.1
|
||||||
uv run ruff check . --output-format=json > ruff-diagnostics.json
|
uv run python scripts/ruff_ratchet.py --base v0.3.0
|
||||||
ruff_status=$?
|
|
||||||
set -e
|
|
||||||
test "$ruff_status" -eq 0 -o "$ruff_status" -eq 1
|
|
||||||
uv run python scripts/ruff_ratchet.py ruff-diagnostics.json
|
|
||||||
- name: Record exact source migration inventory
|
- name: Record exact source migration inventory
|
||||||
run: uv run python -c 'from app.db.migrations import migration_inventory; print("\n".join(migration_inventory()))' > migration-inventory.txt
|
run: uv run python -c 'from app.db.migrations import migration_inventory; print("\n".join(migration_inventory()))' > migration-inventory.txt
|
||||||
- name: Create excluded synthetic canary
|
- name: Create excluded synthetic canary
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
@@ -23,14 +25,10 @@ jobs:
|
|||||||
- run: uv run pytest -q
|
- run: uv run pytest -q
|
||||||
- name: Honest scoped lint for release additions
|
- name: Honest scoped lint for release additions
|
||||||
run: uv run ruff check --select F,I app/db/migrations.py scripts/image_release.py scripts/ruff_ratchet.py tests/release
|
run: uv run ruff check --select F,I app/db/migrations.py scripts/image_release.py scripts/ruff_ratchet.py tests/release
|
||||||
- name: Reject increases over the v0.2.1 Ruff debt baseline
|
- name: Reject Ruff findings introduced by v0.3.0 or its patch
|
||||||
run: |
|
run: |
|
||||||
set +e
|
uv run python scripts/ruff_ratchet.py --base v0.2.1
|
||||||
uv run ruff check . --output-format=json > ruff-diagnostics.json
|
uv run python scripts/ruff_ratchet.py --base v0.3.0
|
||||||
ruff_status=$?
|
|
||||||
set -e
|
|
||||||
test "$ruff_status" -eq 0 -o "$ruff_status" -eq 1
|
|
||||||
uv run python scripts/ruff_ratchet.py ruff-diagnostics.json
|
|
||||||
- name: Validate container contracts without registry writes
|
- name: Validate container contracts without registry writes
|
||||||
run: uv run pytest -q tests/release
|
run: uv run pytest -q tests/release
|
||||||
- name: Build and smoke exact pull-request or main source image
|
- name: Build and smoke exact pull-request or main source image
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ Image rollback is **not** database rollback. Selecting older application bytes d
|
|||||||
|
|
||||||
## API verification and known caller blockers
|
## API verification and known caller blockers
|
||||||
|
|
||||||
v0.3.1 preserves Bearer authentication, retains all-source unified-ledger reads, and adds stock-consolidation corporate-action recording and replay. The release gate runs the complete portfolio-hub source test suite (including auth success/failure, strategy catalog/data, portfolio and ledger APIs), then verifies the built image's health/OpenAPI surface, exact 31-file migration inventory, isolated PostgreSQL migration twice, readiness at `0031`, persistent-volume restart, non-root/read-only operation, secret canary absence, and graceful SIGTERM. Publication consumes the tested archive without rebuilding it. The repository-wide Ruff debt ratchet rejects any total or per-rule increase above the frozen v0.2.1 baseline.
|
v0.3.1 preserves Bearer authentication, retains all-source unified-ledger reads, and adds stock-consolidation corporate-action recording and replay. The release gate runs the complete portfolio-hub source test suite (including auth success/failure, strategy catalog/data, portfolio and ledger APIs), then verifies the built image's health/OpenAPI surface, exact 31-file migration inventory, isolated PostgreSQL migration twice, readiness at `0031`, persistent-volume restart, non-root/read-only operation, secret canary absence, and graceful SIGTERM. Publication consumes the tested archive without rebuilding it. The changed-scope Ruff gate uses the locked linter and the ACMR Python path selector, compares normalized findings against both v0.2.1 (the reported v0.3.0 scope) and v0.3.0 (the patch scope), and rejects every newly introduced finding.
|
||||||
|
|
||||||
The strategy catalog API remains additive: strategy responses expose `nick`, admin create/update requests may set it, and portfolio responses expose the current strategy as `strategy_nick`. Existing clients may omit `nick`; supplied values are trimmed and blank-only values are rejected. trex-web v0.2.3 displays `strategy_nick`, but that dependency does not resolve or waive the separate caller blockers below.
|
The strategy catalog API remains additive: strategy responses expose `nick`, admin create/update requests may set it, and portfolio responses expose the current strategy as `strategy_nick`. Existing clients may omit `nick`; supplied values are trimmed and blank-only values are rejected. trex-web v0.2.3 displays `strategy_nick`, but that dependency does not resolve or waive the separate caller blockers below.
|
||||||
|
|
||||||
|
|||||||
+109
-54
@@ -1,71 +1,126 @@
|
|||||||
"""Enforce the repository's v0.2.1 Ruff debt as a decreasing baseline."""
|
"""Reject Ruff findings introduced since a stable release tag."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
BASELINE_VERSION = "v0.2.1"
|
|
||||||
BASELINE_TOTAL = 576
|
|
||||||
BASELINE_BY_RULE = {
|
|
||||||
"B008": 117,
|
|
||||||
"B017": 1,
|
|
||||||
"B018": 1,
|
|
||||||
"BLE001": 19,
|
|
||||||
"C408": 7,
|
|
||||||
"DTZ901": 1,
|
|
||||||
"F401": 65,
|
|
||||||
"F541": 6,
|
|
||||||
"F811": 16,
|
|
||||||
"F841": 20,
|
|
||||||
"FURB157": 2,
|
|
||||||
"I001": 90,
|
|
||||||
"RET501": 2,
|
|
||||||
"RUF015": 2,
|
|
||||||
"RUF059": 8,
|
|
||||||
"RUF100": 1,
|
|
||||||
"SIM102": 1,
|
|
||||||
"SIM103": 1,
|
|
||||||
"SIM114": 2,
|
|
||||||
"SIM115": 8,
|
|
||||||
"SIM117": 13,
|
|
||||||
"TRY004": 1,
|
|
||||||
"TRY203": 9,
|
|
||||||
"UP017": 166,
|
|
||||||
"UP035": 5,
|
|
||||||
"UP037": 4,
|
|
||||||
"UP045": 8,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def baseline_regressions(diagnostics: list[dict[str, Any]]) -> list[str]:
|
def _run(command: list[str], *, cwd: Path, accepted: tuple[int, ...] = (0,)) -> subprocess.CompletedProcess[str]:
|
||||||
"""Return rule-count or total-count increases above the frozen baseline."""
|
result = subprocess.run(command, cwd=cwd, text=True, capture_output=True, check=False)
|
||||||
counts = Counter(item["code"] for item in diagnostics)
|
if result.returncode not in accepted:
|
||||||
regressions = [
|
raise RuntimeError(
|
||||||
f"{code}: {count} > {BASELINE_BY_RULE.get(code, 0)}"
|
f"command failed ({result.returncode}): {' '.join(command)}\n{result.stderr}"
|
||||||
for code, count in sorted(counts.items())
|
)
|
||||||
if count > BASELINE_BY_RULE.get(code, 0)
|
return result
|
||||||
]
|
|
||||||
if len(diagnostics) > BASELINE_TOTAL:
|
|
||||||
regressions.append(f"total: {len(diagnostics)} > {BASELINE_TOTAL}")
|
def changed_python_paths(repo: Path, base: str) -> list[str]:
|
||||||
return regressions
|
result = _run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"diff",
|
||||||
|
"--name-only",
|
||||||
|
"-z",
|
||||||
|
"--diff-filter=ACMR",
|
||||||
|
f"{base}...HEAD",
|
||||||
|
"--",
|
||||||
|
"*.py",
|
||||||
|
],
|
||||||
|
cwd=repo,
|
||||||
|
)
|
||||||
|
return sorted(path for path in result.stdout.split("\0") if path)
|
||||||
|
|
||||||
|
|
||||||
|
def _source_line(root: Path, relative_path: str, row: int) -> str:
|
||||||
|
lines = (root / relative_path).read_text(encoding="utf-8").splitlines()
|
||||||
|
return lines[row - 1].strip() if 0 < row <= len(lines) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def normalized_findings(
|
||||||
|
diagnostics: list[dict[str, object]], root: Path
|
||||||
|
) -> Counter[tuple[str, str, str, str]]:
|
||||||
|
findings: Counter[tuple[str, str, str, str]] = Counter()
|
||||||
|
for diagnostic in diagnostics:
|
||||||
|
filename = Path(str(diagnostic["filename"])).resolve()
|
||||||
|
relative_path = filename.relative_to(root.resolve()).as_posix()
|
||||||
|
location = diagnostic["location"]
|
||||||
|
if not isinstance(location, dict):
|
||||||
|
raise TypeError("Ruff diagnostic location must be an object")
|
||||||
|
row = int(location["row"])
|
||||||
|
finding = (
|
||||||
|
relative_path,
|
||||||
|
str(diagnostic["code"]),
|
||||||
|
str(diagnostic["message"]),
|
||||||
|
_source_line(root, relative_path, row),
|
||||||
|
)
|
||||||
|
findings[finding] += 1
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def _ruff_diagnostics(root: Path, paths: list[str]) -> list[dict[str, object]]:
|
||||||
|
if not paths:
|
||||||
|
return []
|
||||||
|
ruff = shutil.which("ruff")
|
||||||
|
if ruff is None:
|
||||||
|
raise RuntimeError("ruff executable is not available")
|
||||||
|
result = _run(
|
||||||
|
[ruff, "check", *paths, "--output-format=json"],
|
||||||
|
cwd=root,
|
||||||
|
accepted=(0, 1),
|
||||||
|
)
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
raise TypeError("Ruff JSON output must be a list")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def introduced_findings(repo: Path, base: str) -> tuple[list[str], Counter[tuple[str, str, str, str]]]:
|
||||||
|
paths = changed_python_paths(repo, base)
|
||||||
|
if not paths:
|
||||||
|
return paths, Counter()
|
||||||
|
|
||||||
|
current = normalized_findings(_ruff_diagnostics(repo, paths), repo)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="portfoliohub-ruff-base-") as parent:
|
||||||
|
baseline_root = Path(parent) / "tree"
|
||||||
|
_run(["git", "worktree", "add", "--detach", str(baseline_root), base], cwd=repo)
|
||||||
|
try:
|
||||||
|
baseline_paths = [path for path in paths if (baseline_root / path).is_file()]
|
||||||
|
baseline = normalized_findings(
|
||||||
|
_ruff_diagnostics(baseline_root, baseline_paths), baseline_root
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_run(
|
||||||
|
["git", "worktree", "remove", "--force", str(baseline_root)],
|
||||||
|
cwd=repo,
|
||||||
|
)
|
||||||
|
return paths, current - baseline
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("diagnostics", type=Path)
|
parser.add_argument("--base", required=True, help="stable comparison tag")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
diagnostics = json.loads(args.diagnostics.read_text(encoding="utf-8"))
|
|
||||||
regressions = baseline_regressions(diagnostics)
|
repo = Path(
|
||||||
if regressions:
|
_run(["git", "rev-parse", "--show-toplevel"], cwd=Path.cwd()).stdout.strip()
|
||||||
print(f"Ruff debt increased above {BASELINE_VERSION}:")
|
)
|
||||||
for regression in regressions:
|
ruff_version = _run([shutil.which("ruff") or "ruff", "--version"], cwd=repo).stdout.strip()
|
||||||
print(f"- {regression}")
|
paths, introduced = introduced_findings(repo, args.base)
|
||||||
|
print(f"base={args.base} head=HEAD selector=ACMR-python files={len(paths)} tool={ruff_version}")
|
||||||
|
for path in paths:
|
||||||
|
print(f"selected={path}")
|
||||||
|
if introduced:
|
||||||
|
for (path, code, message, source), count in sorted(introduced.items()):
|
||||||
|
print(f"NEW {count}x {path}: {code} {message} | {source}")
|
||||||
|
print(f"introduced_findings={sum(introduced.values())}")
|
||||||
return 1
|
return 1
|
||||||
print(f"Ruff debt ratchet passed: {len(diagnostics)} <= {BASELINE_TOTAL} ({BASELINE_VERSION})")
|
print("introduced_findings=0")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,33 @@
|
|||||||
from scripts.ruff_ratchet import BASELINE_BY_RULE, BASELINE_TOTAL, baseline_regressions
|
from collections import Counter
|
||||||
|
|
||||||
|
from scripts.ruff_ratchet import normalized_findings
|
||||||
|
|
||||||
|
|
||||||
def _diagnostics(**counts: int) -> list[dict[str, str]]:
|
def test_normalized_findings_ignore_line_number_changes(tmp_path):
|
||||||
return [{"code": code} for code, count in counts.items() for _ in range(count)]
|
source = tmp_path / "sample.py"
|
||||||
|
source.write_text("\nvalue = missing\n", encoding="utf-8")
|
||||||
|
diagnostics = [
|
||||||
|
{
|
||||||
|
"filename": str(source),
|
||||||
|
"code": "F821",
|
||||||
|
"message": "Undefined name `missing`",
|
||||||
|
"location": {"row": 2, "column": 9},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
assert normalized_findings(diagnostics, tmp_path) == Counter(
|
||||||
|
{("sample.py", "F821", "Undefined name `missing`", "value = missing"): 1}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_exact_baseline_passes() -> None:
|
def test_normalized_findings_preserve_duplicate_count(tmp_path):
|
||||||
diagnostics = _diagnostics(**BASELINE_BY_RULE)
|
source = tmp_path / "sample.py"
|
||||||
assert len(diagnostics) == BASELINE_TOTAL
|
source.write_text("missing\n", encoding="utf-8")
|
||||||
assert baseline_regressions(diagnostics) == []
|
diagnostic = {
|
||||||
|
"filename": str(source),
|
||||||
|
"code": "F821",
|
||||||
|
"message": "Undefined name `missing`",
|
||||||
|
"location": {"row": 1, "column": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert normalized_findings([diagnostic, diagnostic], tmp_path).total() == 2
|
||||||
def test_new_rule_fails_from_zero_baseline() -> None:
|
|
||||||
assert baseline_regressions([{"code": "E501"}]) == ["E501: 1 > 0"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_existing_rule_increase_fails() -> None:
|
|
||||||
diagnostics = _diagnostics(I001=BASELINE_BY_RULE["I001"] + 1)
|
|
||||||
assert baseline_regressions(diagnostics) == ["I001: 91 > 90"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_reduced_debt_passes() -> None:
|
|
||||||
assert baseline_regressions(_diagnostics(I001=1, F401=1)) == []
|
|
||||||
|
|||||||
Reference in New Issue
Block a user