ci(release): enforce changed-scope Ruff gate
Publish verified PortfolioHub image / verify (push) Canceled after 0s
Publish verified PortfolioHub image / publish (push) Canceled after 0s

This commit is contained in:
2026-09-13 15:41:35 +09:00
parent 1010d257ad
commit 4520fbbfbd
5 changed files with 147 additions and 89 deletions
+4 -8
View File
@@ -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
+5 -7
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
+28 -19
View File
@@ -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)) == []