diff --git a/.github/branch-protection/develop.json b/.github/branch-protection/develop.json index a603ec4..6187955 100644 --- a/.github/branch-protection/develop.json +++ b/.github/branch-protection/develop.json @@ -9,6 +9,9 @@ "Architecture (import-linter)", "Pre-commit", "File length", + "Version bump check", + "Action pinning audit", + "Tests required", "Frontend Build", "Frontend Quality", "Branch-protection contexts sync", diff --git a/.github/branch-protection/main.json b/.github/branch-protection/main.json index d476437..fecf6fd 100644 --- a/.github/branch-protection/main.json +++ b/.github/branch-protection/main.json @@ -9,6 +9,9 @@ "Architecture (import-linter)", "Pre-commit", "File length", + "Version bump check", + "Action pinning audit", + "Tests required", "Frontend Build", "Frontend Quality", "Branch-protection contexts sync", diff --git a/.github/scripts/check_action_pins.py b/.github/scripts/check_action_pins.py new file mode 100644 index 0000000..6490fd0 --- /dev/null +++ b/.github/scripts/check_action_pins.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""Audit GitHub Actions pin shapes against the project policy. + +Policy from `docs/DEVELOPMENT.md#action-pinning-policy`: + +- **First-party** (`actions/*`, `github/*`) — pin to **major tag** + (`@v\\d+`). SHA + trailing `# vN.M.P` comment is also accepted (stricter + posture; used in elevated-permissions workflows like `release.yml`). + +- **`astral-sh/setup-uv`** — pin to **latest patch tag** + (`@vN.M.P`). The maintainers do not publish a floating major tag for + the v8 series; `@v8` would not resolve. SHA + comment also accepted. + +- **Third-party** (anything else, including `aquasecurity/*`, `gitleaks/*`, + `amannn/*`, `release-drafter/*`) — pin to a **40-hex-char SHA** with a + trailing `# vN.M.P` comment naming the resolved tag. A moving branch + or re-tagged release in a supply-chain workflow defeats the point of + the scan/automation. + +The script walks every YAML file under `.github/workflows/`, extracts +each `uses:` line with its trailing comment if any, and validates the +pin against the policy bucket the action falls into. + +Exit codes: + 0 — every action invocation matches policy + 1 — one or more violations + 2 — script-level error (no workflow files found, parse failure) + +Usage (from repo root): + + python .github/scripts/check_action_pins.py + +Bumping a third-party action: open a focused PR that updates the SHA +*and* the trailing comment. Dependabot's `github-actions` ecosystem +opens those PRs automatically when new SHAs land. +""" + +from __future__ import annotations + +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +WORKFLOWS_DIR = Path(".github/workflows") +# Composite actions live under `.github/actions//action.yml` and can +# carry their own `uses:` invocations of third-party actions in their +# `runs.steps` block. Walk this dir alongside workflows (#137). +ACTIONS_DIR = Path(".github/actions") + +# Captures `uses: @` lines, optionally with a trailing comment. +# Tolerant of the leading `- ` (list item) or no leading dash, and any +# whitespace indent. +_USES_RE = re.compile(r"^\s*-?\s*uses:\s*(?P\S+)\s*(?P#.*)?$") + +_SHA_RE = re.compile(r"^[0-9a-f]{40}$") +_MAJOR_TAG_RE = re.compile(r"^v\d+$") +_PATCH_TAG_RE = re.compile(r"^v\d+\.\d+\.\d+$") +# A `# vN.M.P` annotation in the trailing comment. We accept partial +# semver (`# v5`, `# v4.2`) because some upstream tags are minor-only, +# but we require at least the leading `v\d+`. +_VERSION_COMMENT_RE = re.compile(r"v\d+(\.\d+)*") + + +@dataclass(frozen=True) +class ActionRef: + """One `uses:` invocation: file:line, the @ref, and trailing comment.""" + + file: Path + line: int + action: str # part before @, e.g. "actions/checkout" + pin: str # part after @, e.g. "v4" or "11bd71..." + comment: str | None # raw trailing comment incl. `#`, or None + + def loc(self) -> str: + return f"{self.file}:{self.line}" + + +def parse_workflow(path: Path) -> list[ActionRef]: + """Return every action invocation in a workflow or composite-action file. + + Walks `uses:` lines, including those inside composite-action `runs.steps` + blocks (#137). Skips local-path references (`uses: ./...`) — those point + at repo-internal files (reusable workflows / composite actions in the + same repo) and don't carry a third-party-action pin to validate. + """ + refs: list[ActionRef] = [] + for line_num, line in enumerate( + path.read_text(encoding="utf-8").splitlines(), start=1 + ): + match = _USES_RE.match(line) + if not match: + continue + ref_value = match.group("ref") + # Skip local-path references — these point at repo-internal files + # (reusable workflows or composite actions in this same repo) and + # don't carry an external pin shape we should validate. The path + # may have a `@ref` suffix (reusable workflows) or none (composite + # actions); either is fine. + if ref_value.startswith(("./", "../")): + continue + if "@" not in ref_value: + # Malformed — surface as a special error in validate_ref. + refs.append( + ActionRef( + file=path, + line=line_num, + action=ref_value, + pin="", + comment=match.group("comment"), + ) + ) + continue + action, pin = ref_value.split("@", 1) + refs.append( + ActionRef( + file=path, + line=line_num, + action=action, + pin=pin, + comment=match.group("comment"), + ) + ) + return refs + + +def classify(action: str) -> str: + """Return the pin-shape policy bucket for a given action. + + - "patch-tag" — astral-sh/setup-uv (no floating major tag) + - "major-tag" — actions/* and github/* (first-party) + - "third-party-sha" — everyone else (default for unknown) + """ + if action == "astral-sh/setup-uv": + return "patch-tag" + if action.startswith(("actions/", "github/")): + return "major-tag" + return "third-party-sha" + + +def validate_ref(ref: ActionRef) -> str | None: + """Return an error message if `ref` violates policy, else None. + + SHA pins are always accepted (most-strict shape) for any action, + provided the trailing comment names the resolved version. Tag pins + are accepted only for the buckets the policy documents. + """ + if not ref.pin: + return f"missing @ref on `uses: {ref.action}`" + + bucket = classify(ref.action) + + # SHA pins are stricter than tag pins; accept everywhere as long + # as a trailing version comment is present. The comment ties the + # opaque hash back to a human-reviewable changelog. + if _SHA_RE.match(ref.pin): + if not ref.comment or not _VERSION_COMMENT_RE.search(ref.comment): + return ( + f"{ref.action}@{ref.pin[:8]}… — SHA pin missing trailing " + "`# vN.M.P` comment" + ) + return None + + # Tag pins — must match the bucket. + if bucket == "patch-tag": + if not _PATCH_TAG_RE.match(ref.pin): + return ( + f"{ref.action}@{ref.pin} — astral-sh/setup-uv has no floating " + "major tag for the v8 series; pin to a patch tag (e.g. " + "`@v8.0.0`) or a SHA + trailing `# vN.M.P` comment" + ) + return None + + if bucket == "major-tag": + if not _MAJOR_TAG_RE.match(ref.pin): + return ( + f"{ref.action}@{ref.pin} — first-party action requires major " + "tag (`@v4`) or SHA + trailing `# vN.M.P` comment" + ) + return None + + # third-party-sha — only SHA + comment is acceptable. + return ( + f"{ref.action}@{ref.pin} — third-party action requires SHA pin " + "(40 hex chars) with trailing `# vN.M.P` comment, not a tag" + ) + + +def _collect_yaml_files() -> list[Path]: + """Workflow files + composite-action files. Composite dir is optional.""" + files: list[Path] = [] + files.extend(sorted(WORKFLOWS_DIR.glob("*.yml"))) + files.extend(sorted(WORKFLOWS_DIR.glob("*.yaml"))) + if ACTIONS_DIR.is_dir(): + # `.github/actions//action.yml` (or `action.yaml`). Recursive + # glob so nested composite actions are picked up. + files.extend(sorted(ACTIONS_DIR.glob("**/action.yml"))) + files.extend(sorted(ACTIONS_DIR.glob("**/action.yaml"))) + return files + + +def main() -> int: + if not WORKFLOWS_DIR.is_dir(): + print(f"::error::workflows dir not found: {WORKFLOWS_DIR}") + return 2 + + yml_files = _collect_yaml_files() + if not yml_files: + print(f"::error::no workflow files in {WORKFLOWS_DIR}") + return 2 + + refs: list[ActionRef] = [] + for path in yml_files: + refs.extend(parse_workflow(path)) + + if not refs: + print(f"::error::no `uses:` lines found across {len(yml_files)} workflow files") + return 2 + + failed = False + for ref in refs: + problem = validate_ref(ref) + if problem is None: + continue + failed = True + # GitHub Actions error annotation: surfaces inline on the file in PR review. + print(f"::error file={ref.file},line={ref.line}::{problem}") + + if failed: + print( + "\nSee docs/DEVELOPMENT.md#action-pinning-policy for the rule. " + "Dependabot's `github-actions` ecosystem opens SHA-bump PRs " + "automatically when new tags ship." + ) + return 1 + + print( + f"Action pins audit OK — {len(refs)} pins, {len(yml_files)} files " + f"(workflows + composite actions)." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/check_tests_present.py b/.github/scripts/check_tests_present.py new file mode 100644 index 0000000..3c689b1 --- /dev/null +++ b/.github/scripts/check_tests_present.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Verify behaviour-changing PRs ship tests alongside the src/ change. + +`docs/DEVELOPMENT.md` *Testing Policy*: every PR that adds or changes +behaviour must include tests. Today only the 75% coverage gate +approximates this — a PR can drop coverage from 95% to 76% on +uncovered new code and still pass. This gate adds direct enforcement. + +Behaviour: + +- Reads `git diff --name-only origin/...HEAD` for `src/**/*.py` + and `tests/**/*.py`. +- Determines the PR's commit-type prefix from the event payload's + `pull_request.title` (same source as `check_version_bump.py`). +- If `src/**/*.py` files changed AND no `tests/**/*.py` file was + touched in the same diff: + - **Block (exit 1)** when the prefix is `feat:` or `fix:` — these + types declare a behaviour change that the policy says must come + with tests. + - **Warn (exit 0)** for `chore:`, `docs:`, `refactor:`, `test:`, + `release:` — these may legitimately touch `src/` without new + tests (config rename, internal-only refactor, doc-string fix). + +Exit codes: + 0 — pass (or warn-only) + 1 — block: behaviour-change PR shipped without tests + 2 — script-level error (missing env, parse failure) + +Usage (CI, on pull_request events): + + python .github/scripts/check_tests_present.py + +Required env (set automatically by GitHub Actions): +- ``GITHUB_BASE_REF`` — base branch name (e.g. "develop") +- ``GITHUB_EVENT_PATH`` — path to event JSON (for PR title) +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +# Prefixes that declare a behaviour change → tests required. +BLOCKING_PREFIXES: frozenset[str] = frozenset({"feat", "fix"}) + +# Prefixes that may legitimately touch src/ without new tests → warn-only. +WARN_ONLY_PREFIXES: frozenset[str] = frozenset( + {"chore", "docs", "refactor", "test", "release"} +) + + +def pr_title_from_event() -> str | None: + """Read the PR title from the GitHub Actions event payload, if present.""" + event_path = os.environ.get("GITHUB_EVENT_PATH") + if not event_path: + return None + try: + data = json.loads(Path(event_path).read_text(encoding="utf-8")) + except OSError, json.JSONDecodeError: + return None + pr = data.get("pull_request") + if not isinstance(pr, dict): + return None + title = pr.get("title") + return title if isinstance(title, str) else None + + +def commit_type_prefix(title: str | None) -> str | None: + """Extract the conventional-commit type prefix from a PR title. + + Examples: "feat: add tool" → "feat", "chore(api): rename" → "chore", + "release: v1.2.3" → "release". Returns None if the title doesn't + contain a colon (i.e. doesn't match the `type: subject` shape). + """ + if not title or ":" not in title: + return None + head = title.lstrip() + # Type ends at the first of "(", "!", ":" — whichever comes first. + # The colon is mandatory (already checked above), so we always find one. + earliest = len(head) + for terminator in ("(", "!", ":"): + idx = head.find(terminator) + if 0 < idx < earliest: + earliest = idx + return head[:earliest].lower() if earliest > 0 else None + + +def changed_files(base_ref: str) -> list[str]: + """Return paths changed between `origin/` and HEAD.""" + try: + out = subprocess.check_output( # noqa: S603 - args are CI-trusted + [ # noqa: S607 - git on PATH + "git", + "diff", + "--name-only", + f"origin/{base_ref}...HEAD", + ], + text=True, + stderr=subprocess.PIPE, + ) + except subprocess.CalledProcessError as e: + msg = ( + f"git diff origin/{base_ref}...HEAD failed: " + f"{e.stderr.strip() or e.returncode}" + ) + raise RuntimeError(msg) from e + return [line for line in out.splitlines() if line.strip()] + + +def _is_under(path: str, dirname: str) -> bool: + """True when `path` is inside `/`.""" + return path.startswith(dirname + "/") or path == dirname + + +def main() -> int: + base_ref = os.environ.get("GITHUB_BASE_REF") + if not base_ref: + print("::warning::GITHUB_BASE_REF not set; skipping (not a PR run).") + return 0 + + title = pr_title_from_event() + prefix = commit_type_prefix(title) + + try: + files = changed_files(base_ref) + except RuntimeError as e: + print(f"::error::{e}") + return 2 + + src_py = [f for f in files if _is_under(f, "src") and f.endswith(".py")] + tests_py = [f for f in files if _is_under(f, "tests") and f.endswith(".py")] + + if not src_py: + print("No `src/**/*.py` changes — gate not applicable.") + return 0 + + if tests_py: + print( + f"src/ changed ({len(src_py)} file(s)); tests/ also touched " + f"({len(tests_py)} file(s)). OK." + ) + return 0 + + # src/ changed, tests/ untouched — decide based on prefix. + src_summary = ", ".join(src_py[:5]) + ( + f" (+{len(src_py) - 5} more)" if len(src_py) > 5 else "" + ) + + if prefix in BLOCKING_PREFIXES: + print( + f"::error::PR title prefix `{prefix}:` declares a behaviour " + "change but the diff touches `src/` without any " + "`tests/**/*.py` change. Per `docs/DEVELOPMENT.md` Testing " + "Policy, every behaviour-change PR ships tests.\n" + f" src/ files changed: {src_summary}\n" + " Fix: add or update a test in `tests/`, or restructure the " + "PR if the change is genuinely test-exempt (rename, comment-only, " + "internal refactor) — and use a different commit-type prefix " + f"({sorted(WARN_ONLY_PREFIXES)})." + ) + return 1 + + if prefix in WARN_ONLY_PREFIXES: + print( + f"::warning::PR title prefix `{prefix}:` touches `src/` " + "without `tests/` changes. Allowed for this prefix; " + "double-check that no behaviour change slipped in.\n" + f" src/ files changed: {src_summary}" + ) + return 0 + + # Unknown / missing prefix — fall through to warn-only. The + # `Lint PR title` and `Commit-type sync` gates already enforce + # the 7-prefix list elsewhere; this script doesn't duplicate that. + print( + f"::warning::PR title prefix not recognised ({prefix!r}); " + "tests-required gate skipped. The `Lint PR title` job is the " + "source of truth for prefix validation." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/check_version_bump.py b/.github/scripts/check_version_bump.py new file mode 100644 index 0000000..4510c0f --- /dev/null +++ b/.github/scripts/check_version_bump.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Verify pyproject version was bumped on this PR. + +Per `docs/DEVELOPMENT.md`: every non-`release:` PR bumps `[project] version` +in `pyproject.toml`. `release:` PRs are exempt — the dev version IS the +release version. The gate does not enforce semver direction (feat → MINOR +vs fix → PATCH); it only enforces that *some* bump happened, since +direction is conventionally documented but mechanically ambiguous. + +Sibling check: `uv.lock`'s `[[package]] name = "harness-python-react"` +block must show the same version. Hand-edit the lockfile to avoid silent +transitive-dep upgrades that `uv lock` would pull in. + +Usage (CI, on pull_request events): + + python .github/scripts/check_version_bump.py + +Required env (set by GitHub Actions on `pull_request`): + +- `GITHUB_BASE_REF` — base branch name (e.g. "develop") +- `GITHUB_EVENT_PATH` — path to event JSON (used to read PR title) + +Exit codes: + 0 — version bumped (or `release:` PR, exempt) + 1 — version not bumped, or pyproject + uv.lock disagree + 2 — script-level error (missing env, parse failure) +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import tomllib +from pathlib import Path + +PYPROJECT = Path("pyproject.toml") +UV_LOCK = Path("uv.lock") +PACKAGE_NAME = "harness-python-react" + +# Match the project's self-version block in uv.lock: +# +# [[package]] +# name = "harness-python-react" +# version = "0.1.0" +# +# Tolerant of whitespace variation across uv versions. +_LOCK_BLOCK_RE = re.compile( + r"\[\[package\]\]\s*\n" + rf'name\s*=\s*"{re.escape(PACKAGE_NAME)}"\s*\n' + r'version\s*=\s*"([^"]+)"', +) + + +def pyproject_version(toml_text: str) -> str: + """Extract `[project] version` from a pyproject.toml string.""" + data = tomllib.loads(toml_text) + version = data.get("project", {}).get("version") + if not isinstance(version, str) or not version: + msg = "[project] version not found in pyproject.toml" + raise ValueError(msg) + return version + + +def uv_lock_self_version(lock_text: str) -> str: + """Extract the project self-version from a uv.lock string.""" + match = _LOCK_BLOCK_RE.search(lock_text) + if not match: + msg = ( + f'Could not find [[package]] name = "{PACKAGE_NAME}" block in ' + "uv.lock. Has the project name changed?" + ) + raise ValueError(msg) + return match.group(1) + + +def git_show_at_base(path: Path, base_ref: str) -> str: + """Read a tracked file's content at `origin/` via git. + + `base_ref` is sourced from `GITHUB_BASE_REF` which GitHub Actions sets to + a validated branch name; `path` is a static `Path` constant in this module. + Neither is user input, so the bandit S603/S607 warnings on the subprocess + call are suppressed. + """ + try: + return subprocess.check_output( # noqa: S603 - args are CI-trusted + ["git", "show", f"origin/{base_ref}:{path}"], # noqa: S607 - git on PATH + text=True, + stderr=subprocess.PIPE, + ) + except subprocess.CalledProcessError as e: + msg = ( + f"git show origin/{base_ref}:{path} failed: " + f"{e.stderr.strip() or e.returncode}" + ) + raise RuntimeError(msg) from e + + +def pr_title_from_event() -> str | None: + """Read the PR title from the GitHub Actions event payload, if present.""" + event_path = os.environ.get("GITHUB_EVENT_PATH") + if not event_path: + return None + try: + data = json.loads(Path(event_path).read_text(encoding="utf-8")) + except OSError, json.JSONDecodeError: + return None + pr = data.get("pull_request") + if not isinstance(pr, dict): + return None + title = pr.get("title") + return title if isinstance(title, str) else None + + +def is_release_pr(title: str | None) -> bool: + """A `release:` PR is exempt from the bump requirement.""" + if not title: + return False + return title.lstrip().lower().startswith("release:") + + +def main() -> int: + base_ref = os.environ.get("GITHUB_BASE_REF") + if not base_ref: + print("::warning::GITHUB_BASE_REF not set; skipping (not a PR run).") + return 0 + + title = pr_title_from_event() + if is_release_pr(title): + print(f"release: PR — version bump not required ({title!r})") + return 0 + + try: + head_pp = pyproject_version(PYPROJECT.read_text(encoding="utf-8")) + head_lock = uv_lock_self_version(UV_LOCK.read_text(encoding="utf-8")) + base_pp = pyproject_version(git_show_at_base(PYPROJECT, base_ref)) + except (OSError, ValueError, RuntimeError) as e: + print(f"::error::{e}") + return 2 + + print(f"Base ({base_ref}) pyproject version: {base_pp}") + print(f"HEAD pyproject version: {head_pp}") + print(f"HEAD uv.lock self-version: {head_lock}") + + failed = False + + if head_pp == base_pp: + print( + f"::error::pyproject version unchanged from {base_ref} " + f"({head_pp}). Per docs/DEVELOPMENT.md every non-release: PR " + "bumps the version (PATCH for fix/refactor/test/docs/chore; " + "MINOR for feat). Hand-edit the self-version line in uv.lock too." + ) + failed = True + else: + print(f"Version bumped: {base_pp} -> {head_pp} OK") + + if head_pp != head_lock: + print( + f"::error::pyproject ({head_pp}) and uv.lock ({head_lock}) " + "self-version disagree. Hand-edit the uv.lock " + f'[[package]] name = "{PACKAGE_NAME}" block to match pyproject.toml.' + ) + failed = True + else: + print("pyproject + uv.lock self-version match OK") + + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99b29e5..fcda20a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,6 +105,52 @@ jobs: python-version: "3.14" - run: python .github/scripts/check_file_length.py + version-bump: + name: Version bump check + runs-on: ubuntu-latest + # Every non-`release:` PR bumps [project] version in pyproject.toml AND + # the matching [[package]] block in uv.lock. Closes the bump-miss class + # that the 75 % coverage gate cannot detect on its own. + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 # full history so `git show origin/:` resolves + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.14" + - run: python .github/scripts/check_version_bump.py + + action-pinning: + name: Action pinning audit + runs-on: ubuntu-latest + # Validates every `uses:` line in .github/workflows/ + .github/actions/ + # against the policy in docs/DEVELOPMENT.md#action-pinning-policy. + # First-party = major tag; astral-sh/setup-uv = patch tag; third-party + # = SHA + trailing `# vN.M.P` comment. + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.14" + - run: python .github/scripts/check_action_pins.py + + tests-required: + name: Tests required + runs-on: ubuntu-latest + # `feat:` / `fix:` PRs that touch `src/` must touch `tests/` too. + # Per docs/DEVELOPMENT.md Testing Policy. Other prefixes get a warn-only + # `::warning::` if src/ is touched without tests/. + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 # full history so the diff resolves + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.14" + - run: python .github/scripts/check_tests_present.py + frontend-build: name: Frontend Build runs-on: ubuntu-latest diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index b42f235..c844ffc 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -92,7 +92,7 @@ Subject is **lowercase after the colon** (Title Case is rejected unless it's an | Workflow | Triggers | Required? | |---|---|---| -| `ci.yml` | push/PR to develop+main | Yes — 8 backend + 2 frontend jobs | +| `ci.yml` | push/PR to develop+main | Yes — backend + frontend gates + meta-gates + version/action/tests audits | | `security.yml` | push/PR + weekly schedule | Yes — 4 jobs (gitleaks, pip-audit, npm audit, trivy) | | `pr-title.yml` | PR open/edit/sync | Yes — conventional-commit lint | | `release.yml` | tag `v*.*.*` | No — tag-triggered | @@ -102,6 +102,26 @@ Subject is **lowercase after the colon** (Title Case is rejected unless it's an | `eval-nightly.yml` | `workflow_dispatch` only by default | No | | `codeql.yml` | `workflow_dispatch` only (placeholder) | No | +### Action-pinning policy + +Audited by the `Action pinning audit` CI job (`.github/scripts/check_action_pins.py`). Three buckets: + +- **First-party** (`actions/*`, `github/*`) — pin to **major tag** (`@v4`). SHA + trailing `# vN.M.P` comment also accepted; that's the form used in elevated-permissions workflows (release, branch-protection). +- **`astral-sh/setup-uv`** — pin to **latest patch tag** (`@v8.0.0`) or SHA. astral-sh does not publish a floating major tag for v8; `@v8` would not resolve. +- **Third-party** (anything else) — pin to a **40-hex-char SHA** with a trailing `# vN.M.P` comment naming the resolved tag. A moving branch in a supply-chain workflow defeats the point of the scan. + +When bumping a third-party action, update the SHA *and* the trailing comment in the same PR. Dependabot's `github-actions` ecosystem opens those PRs automatically. + +### Version-bump policy + +Audited by the `Version bump check` CI job (`.github/scripts/check_version_bump.py`). Every PR bumps `[project] version` in `pyproject.toml` AND the matching `[[package]]` block in `uv.lock`. The bump direction follows commitizen's `bump_map` in `pyproject.toml` — `feat:` is MINOR, everything else is PATCH. `release:` PRs are exempt because the dev version IS the release version. + +The `uv.lock` self-version is hand-edited (one line); avoid `uv lock` mid-PR because it would re-resolve transitive deps and pull in unintended upgrades. The `Version bump check` gate enforces both halves. + +### Testing policy + +Audited by the `Tests required` CI job (`.github/scripts/check_tests_present.py`). `feat:` and `fix:` PRs that touch `src/` MUST also touch `tests/`. Other prefixes get a `::warning::` if `src/` is touched without tests but don't block. The 75 % coverage gate alone doesn't catch behaviour-change-without-test (a single new line on already-covered code can pass), which is why this is a separate axis. + ## Local agent hook setup The `.claude/hooks/` scripts enforce the harness from the LLM-coder side: blocking `--no-verify`, scanning staged diffs for secrets, formatting after every Write/Edit. Opt in by copying the example settings file: diff --git a/pyproject.toml b/pyproject.toml index 1789d98..27e014a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "harness-python-react" -version = "0.1.0" +version = "0.2.0" description = "Production-quality LLM-driven coding harness — Python (FastAPI) backend, Vite + React + TypeScript frontend." readme = "README.md" requires-python = ">=3.14" diff --git a/tests/test_check_action_pins.py b/tests/test_check_action_pins.py new file mode 100644 index 0000000..9167dc0 --- /dev/null +++ b/tests/test_check_action_pins.py @@ -0,0 +1,270 @@ +"""Tests for `.github/scripts/check_action_pins.py`. + +Cover the policy buckets, the `parse_workflow` line extractor (including +trailing comments and missing `@ref`), and `main()` end-to-end against +fabricated workflow files in `tmp_path`. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from typing import Any + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPT_PATH = REPO_ROOT / ".github" / "scripts" / "check_action_pins.py" + + +def _load_script() -> Any: + spec = importlib.util.spec_from_file_location("check_action_pins", SCRIPT_PATH) + if spec is None or spec.loader is None: + msg = f"Could not load script at {SCRIPT_PATH}" + raise RuntimeError(msg) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +cap = _load_script() + + +# ---------- classify ---------- + + +@pytest.mark.parametrize( + "action,bucket", + [ + ("astral-sh/setup-uv", "patch-tag"), + ("actions/checkout", "major-tag"), + ("actions/setup-python", "major-tag"), + ("actions/setup-node", "major-tag"), + ("actions/upload-artifact", "major-tag"), + ("github/codeql-action", "major-tag"), + ("github/codeql-action/init", "major-tag"), + ("github/codeql-action/analyze", "major-tag"), + ("aquasecurity/trivy-action", "third-party-sha"), + ("gitleaks/gitleaks-action", "third-party-sha"), + ("amannn/action-semantic-pull-request", "third-party-sha"), + ("release-drafter/release-drafter", "third-party-sha"), + ("some-other-org/random-action", "third-party-sha"), + ], +) +def test_classify(action: str, bucket: str) -> None: + assert cap.classify(action) == bucket + + +# ---------- validate_ref ---------- + + +def _ref(action: str, pin: str, comment: str | None = None) -> Any: + return cap.ActionRef( + file=Path("dummy.yml"), line=1, action=action, pin=pin, comment=comment + ) + + +def test_validate_first_party_major_tag_ok() -> None: + assert cap.validate_ref(_ref("actions/checkout", "v4")) is None + + +def test_validate_first_party_patch_tag_rejected() -> None: + err = cap.validate_ref(_ref("actions/checkout", "v4.2.2")) + assert err is not None + assert "first-party" in err and "major tag" in err + + +def test_validate_first_party_sha_with_comment_ok() -> None: + sha = "11bd71901bbe5b1630ceea73d27597364c9af683" + assert cap.validate_ref(_ref("actions/checkout", sha, "# v4.2.2")) is None + + +def test_validate_first_party_sha_without_comment_rejected() -> None: + sha = "11bd71901bbe5b1630ceea73d27597364c9af683" + err = cap.validate_ref(_ref("actions/checkout", sha)) + assert err is not None and "missing trailing" in err + + +def test_validate_setup_uv_patch_tag_ok() -> None: + assert cap.validate_ref(_ref("astral-sh/setup-uv", "v8.0.0")) is None + + +def test_validate_setup_uv_major_tag_rejected() -> None: + err = cap.validate_ref(_ref("astral-sh/setup-uv", "v8")) + assert err is not None + assert "no floating major tag" in err + + +def test_validate_setup_uv_sha_with_comment_ok() -> None: + sha = "cec208311dfd045dd5311c1add060b2062131d57" + assert cap.validate_ref(_ref("astral-sh/setup-uv", sha, "# v8.0.0")) is None + + +def test_validate_third_party_tag_rejected() -> None: + err = cap.validate_ref(_ref("release-drafter/release-drafter", "v6")) + assert err is not None + assert "third-party" in err and "SHA pin" in err + + +def test_validate_third_party_sha_with_comment_ok() -> None: + sha = "48f256284bd46cdaab1048c3721360e808335d50" + assert ( + cap.validate_ref(_ref("amannn/action-semantic-pull-request", sha, "# v6.1.1")) + is None + ) + + +def test_validate_third_party_sha_without_comment_rejected() -> None: + sha = "48f256284bd46cdaab1048c3721360e808335d50" + err = cap.validate_ref(_ref("amannn/action-semantic-pull-request", sha)) + assert err is not None and "missing trailing" in err + + +def test_validate_partial_version_comment_accepted() -> None: + # `# v5` is accepted — some upstream tags are major-only. + sha = "a26af69be951a213d495a4c3e4e4022e16d87065" + assert cap.validate_ref(_ref("actions/setup-python", sha, "# v5")) is None + + +def test_validate_comment_without_version_rejected() -> None: + sha = "a26af69be951a213d495a4c3e4e4022e16d87065" + err = cap.validate_ref(_ref("actions/setup-python", sha, "# pinned for security")) + assert err is not None and "missing trailing" in err + + +def test_validate_missing_at_ref_rejected() -> None: + err = cap.validate_ref(_ref("actions/checkout", "")) + assert err is not None and "missing @ref" in err + + +def test_validate_third_party_short_hex_rejected() -> None: + # Looks like a SHA but isn't 40 chars; falls through to tag-shape check. + err = cap.validate_ref(_ref("aquasecurity/trivy-action", "abc123", "# v0.36.0")) + assert err is not None # third-party with non-SHA pin + + +# ---------- parse_workflow ---------- + + +def test_parse_workflow_extracts_refs(tmp_path: Path) -> None: + yml = tmp_path / "wf.yml" + setup_python_sha = "a26af69be951a213d495a4c3e4e4022e16d87065" + yml.write_text( + "jobs:\n" + " build:\n" + " steps:\n" + " - uses: actions/checkout@v4\n" + " - uses: astral-sh/setup-uv@v8.0.0\n" + f" - uses: actions/setup-python@{setup_python_sha} # v5\n" + " - run: echo hi\n", + encoding="utf-8", + ) + refs = cap.parse_workflow(yml) + assert len(refs) == 3 + assert refs[0].action == "actions/checkout" + assert refs[0].pin == "v4" + assert refs[0].comment is None + assert refs[1].action == "astral-sh/setup-uv" + assert refs[1].pin == "v8.0.0" + assert refs[2].action == "actions/setup-python" + assert refs[2].comment == "# v5" + + +def test_parse_workflow_handles_missing_at_ref(tmp_path: Path) -> None: + yml = tmp_path / "wf.yml" + yml.write_text( + "jobs:\n" + " build:\n" + " steps:\n" + " - uses: actions/checkout\n", # malformed — no @ref + encoding="utf-8", + ) + refs = cap.parse_workflow(yml) + assert len(refs) == 1 + assert refs[0].action == "actions/checkout" + assert refs[0].pin == "" + + +# ---------- main() end-to-end ---------- + + +def _setup_workflows_dir(tmp_path: Path, files: dict[str, str]) -> Path: + """Create a tmp `.github/workflows/` and chdir into the parent.""" + wf_dir = tmp_path / ".github" / "workflows" + wf_dir.mkdir(parents=True) + for name, content in files.items(): + (wf_dir / name).write_text(content, encoding="utf-8") + return tmp_path + + +def test_main_clean_passes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _setup_workflows_dir( + tmp_path, + { + "ci.yml": ( + "jobs:\n build:\n steps:\n" + " - uses: actions/checkout@v4\n" + " - uses: astral-sh/setup-uv@v8.0.0\n" + ), + }, + ) + monkeypatch.chdir(tmp_path) + assert cap.main() == 0 + assert "Action pins audit OK" in capsys.readouterr().out + + +def test_main_third_party_tag_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _setup_workflows_dir( + tmp_path, + { + "release-drafter.yml": ( + "jobs:\n draft:\n steps:\n" + " - uses: release-drafter/release-drafter@v6\n" + ), + }, + ) + monkeypatch.chdir(tmp_path) + assert cap.main() == 1 + out = capsys.readouterr().out + assert "::error file=" in out + assert "third-party" in out + + +def test_main_setup_uv_major_tag_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _setup_workflows_dir( + tmp_path, + { + "ci.yml": ( + "jobs:\n build:\n steps:\n - uses: astral-sh/setup-uv@v8\n" + ), + }, + ) + monkeypatch.chdir(tmp_path) + assert cap.main() == 1 + out = capsys.readouterr().out + assert "no floating major tag" in out + + +def test_main_no_workflow_files_exits_2( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + (tmp_path / ".github" / "workflows").mkdir(parents=True) + monkeypatch.chdir(tmp_path) + assert cap.main() == 2 + assert "no workflow files" in capsys.readouterr().out + + +def test_main_no_workflows_dir_exits_2( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.chdir(tmp_path) + assert cap.main() == 2 + assert "workflows dir not found" in capsys.readouterr().out diff --git a/tests/test_check_action_pins_composite.py b/tests/test_check_action_pins_composite.py new file mode 100644 index 0000000..b3790fb --- /dev/null +++ b/tests/test_check_action_pins_composite.py @@ -0,0 +1,186 @@ +"""Tests for the composite-action + local-path coverage in check_action_pins (#137). + +Sibling to `tests/test_check_action_pins.py` (which is at the line-cap +edge). Covers the `parse_workflow` extensions added in #137: + +- `.github/actions//action.yml` files are walked alongside workflows. +- Local-path `uses: ./...` references are skipped (not flagged as + missing-`@`). + +`test_check_action_pins.py` covers the workflow case + policy buckets; +this file covers the composite + local-path cases. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from typing import Any +from unittest.mock import patch + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPT_PATH = REPO_ROOT / ".github" / "scripts" / "check_action_pins.py" + + +def _load_script() -> Any: + spec = importlib.util.spec_from_file_location("check_action_pins", SCRIPT_PATH) + if spec is None or spec.loader is None: + msg = f"Could not load script at {SCRIPT_PATH}" + raise RuntimeError(msg) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +cap = _load_script() + + +# ---------- parse_workflow on a composite-action file ---------- + + +def test_parse_walks_composite_action_steps(tmp_path: Path) -> None: + """`.github/actions//action.yml` files surface their `uses:` lines.""" + action_yaml = tmp_path / "action.yml" + action_yaml.write_text( + "name: 'fake composite'\n" + "runs:\n" + " using: composite\n" + " steps:\n" + " - uses: actions/checkout@v4\n" + " - uses: aquasecurity/trivy-action@" + ("a" * 40) + " # v0.36.0\n", + encoding="utf-8", + ) + refs = cap.parse_workflow(action_yaml) + actions = [r.action for r in refs] + assert actions == ["actions/checkout", "aquasecurity/trivy-action"] + + +def test_parse_skips_local_path_uses(tmp_path: Path) -> None: + """`uses: ./...` references resolve in-repo and aren't third-party pins.""" + workflow = tmp_path / "fake.yml" + workflow.write_text( + "jobs:\n" + " reuse:\n" + " uses: ./.github/workflows/composite.yml@main\n" + " call-composite:\n" + " steps:\n" + " - uses: ./.github/actions/local\n" + " - uses: actions/checkout@v4\n", + encoding="utf-8", + ) + refs = cap.parse_workflow(workflow) + actions = [r.action for r in refs] + # Both local-path entries skipped; only the third-party one survives. + assert actions == ["actions/checkout"] + + +def test_parse_skips_dotdot_local_path(tmp_path: Path) -> None: + """`uses: ../action` (rare but legal) is also a local-path skip.""" + workflow = tmp_path / "fake.yml" + workflow.write_text( + "jobs:\n j:\n steps:\n - uses: ../shared/action\n", + encoding="utf-8", + ) + assert cap.parse_workflow(workflow) == [] + + +# ---------- _collect_yaml_files end-to-end ---------- + + +def test_collect_includes_composite_actions(tmp_path: Path) -> None: + """`_collect_yaml_files` walks both workflows and `.github/actions/`.""" + workflows = tmp_path / "workflows" + workflows.mkdir() + (workflows / "ci.yml").write_text( + "jobs:\n j:\n steps:\n - uses: actions/checkout@v4\n", + encoding="utf-8", + ) + actions_dir = tmp_path / "actions" + composite = actions_dir / "publish" + composite.mkdir(parents=True) + (composite / "action.yml").write_text( + "name: 'publish'\nruns:\n using: composite\n steps:\n" + " - uses: actions/setup-node@v5\n", + encoding="utf-8", + ) + with ( + patch.object(cap, "WORKFLOWS_DIR", workflows), + patch.object(cap, "ACTIONS_DIR", actions_dir), + ): + collected = cap._collect_yaml_files() + names = sorted(p.name for p in collected) + assert names == ["action.yml", "ci.yml"] + + +def test_collect_handles_missing_actions_dir(tmp_path: Path) -> None: + """Repo without `.github/actions/` (today's shape) still scans workflows.""" + workflows = tmp_path / "workflows" + workflows.mkdir() + (workflows / "ci.yml").write_text( + "jobs:\n j:\n steps:\n - uses: actions/checkout@v4\n", + encoding="utf-8", + ) + missing_actions = tmp_path / "no-such-dir" + with ( + patch.object(cap, "WORKFLOWS_DIR", workflows), + patch.object(cap, "ACTIONS_DIR", missing_actions), + ): + collected = cap._collect_yaml_files() + assert [p.name for p in collected] == ["ci.yml"] + + +# ---------- main() end-to-end through composite + local-path ---------- + + +def test_main_flags_violation_in_composite_action(tmp_path: Path, capsys: Any) -> None: + """A bad pin inside a composite action's steps fails the audit.""" + workflows = tmp_path / "workflows" + workflows.mkdir() + (workflows / "ci.yml").write_text( + "jobs:\n j:\n steps:\n - uses: actions/checkout@v4\n", + encoding="utf-8", + ) + actions_dir = tmp_path / "actions" + bad = actions_dir / "bad" + bad.mkdir(parents=True) + # Tag pin on a third-party action — violates third-party-sha policy. + (bad / "action.yml").write_text( + "name: 'bad'\nruns:\n using: composite\n steps:\n" + " - uses: aquasecurity/trivy-action@v0.36.0\n", + encoding="utf-8", + ) + with ( + patch.object(cap, "WORKFLOWS_DIR", workflows), + patch.object(cap, "ACTIONS_DIR", actions_dir), + ): + assert cap.main() == 1 + out = capsys.readouterr().out + assert "aquasecurity/trivy-action" in out + assert "third-party action requires SHA pin" in out + + +def test_main_passes_with_clean_composite_action(tmp_path: Path, capsys: Any) -> None: + """A clean composite action with a SHA + comment passes the audit.""" + workflows = tmp_path / "workflows" + workflows.mkdir() + (workflows / "ci.yml").write_text( + "jobs:\n j:\n steps:\n - uses: actions/checkout@v4\n", + encoding="utf-8", + ) + actions_dir = tmp_path / "actions" + good = actions_dir / "good" + good.mkdir(parents=True) + (good / "action.yml").write_text( + "name: 'good'\nruns:\n using: composite\n steps:\n" + " - uses: aquasecurity/trivy-action@" + ("a" * 40) + " # v0.36.0\n", + encoding="utf-8", + ) + with ( + patch.object(cap, "WORKFLOWS_DIR", workflows), + patch.object(cap, "ACTIONS_DIR", actions_dir), + ): + assert cap.main() == 0 + out = capsys.readouterr().out + assert "Action pins audit OK" in out diff --git a/tests/test_check_tests_present.py b/tests/test_check_tests_present.py new file mode 100644 index 0000000..c050d43 --- /dev/null +++ b/tests/test_check_tests_present.py @@ -0,0 +1,219 @@ +"""Tests for `.github/scripts/check_tests_present.py`.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pytest + +if TYPE_CHECKING: + from collections.abc import Iterable + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPT_PATH = REPO_ROOT / ".github" / "scripts" / "check_tests_present.py" + + +def _load_script() -> Any: + spec = importlib.util.spec_from_file_location("check_tests_present", SCRIPT_PATH) + if spec is None or spec.loader is None: + msg = f"Could not load script at {SCRIPT_PATH}" + raise RuntimeError(msg) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +ctp = _load_script() + + +# ---------- commit_type_prefix ---------- + + +@pytest.mark.parametrize( + "title,expected", + [ + ("feat: add tool", "feat"), + ("fix: handle empty list", "fix"), + ("chore(api): rename module", "chore"), + ("docs: update readme", "docs"), + ("test: add coverage", "test"), + ("refactor: split module", "refactor"), + ("release: v1.2.3", "release"), + ("feat!: breaking change", "feat"), + ("FEAT: case-insensitive", "feat"), + (" feat: leading-spaces", "feat"), + ("no-prefix subject only", None), + ("", None), + (None, None), + ], +) +def test_commit_type_prefix(title: str | None, expected: str | None) -> None: + assert ctp.commit_type_prefix(title) == expected + + +# ---------- changed_files (via stub) ---------- + + +def test_changed_files_filters_blank_lines(monkeypatch: pytest.MonkeyPatch) -> None: + """Empty lines in git output are filtered out.""" + + def fake_check_output(*_: Any, **__: Any) -> str: + return "src/api/main.py\n\ntests/test_api.py\n" + + monkeypatch.setattr(ctp.subprocess, "check_output", fake_check_output) + assert ctp.changed_files("develop") == ["src/api/main.py", "tests/test_api.py"] + + +def test_changed_files_propagates_git_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A git error becomes a RuntimeError with a context-rich message.""" + + def fake_check_output(*_: Any, **__: Any) -> str: + raise ctp.subprocess.CalledProcessError( + 128, ["git"], stderr="bad object origin/develop" + ) + + monkeypatch.setattr(ctp.subprocess, "check_output", fake_check_output) + with pytest.raises(RuntimeError, match="bad object origin/develop"): + ctp.changed_files("develop") + + +# ---------- main() end-to-end ---------- + + +def _set_event_payload( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, title: str +) -> None: + event_file = tmp_path / "event.json" + event_file.write_text( + json.dumps({"pull_request": {"title": title}}), encoding="utf-8" + ) + monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file)) + + +def _stub_changed_files(monkeypatch: pytest.MonkeyPatch, files: Iterable[str]) -> None: + monkeypatch.setattr(ctp, "changed_files", lambda _base_ref: list(files)) + + +def test_main_no_src_changes_passes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("GITHUB_BASE_REF", "develop") + _set_event_payload(monkeypatch, tmp_path, "feat: docs-only change") + _stub_changed_files(monkeypatch, ["docs/README.md", ".github/workflows/ci.yml"]) + + assert ctp.main() == 0 + assert "gate not applicable" in capsys.readouterr().out + + +def test_main_src_and_tests_changed_passes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("GITHUB_BASE_REF", "develop") + _set_event_payload(monkeypatch, tmp_path, "feat: new tool") + _stub_changed_files(monkeypatch, ["src/tools/new.py", "tests/test_new.py"]) + + assert ctp.main() == 0 + out = capsys.readouterr().out + assert "tests/ also touched" in out + + +@pytest.mark.parametrize("prefix_title", ["feat: x", "fix: y", "FIX: z"]) +def test_main_feat_or_fix_without_tests_blocks( + prefix_title: str, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("GITHUB_BASE_REF", "develop") + _set_event_payload(monkeypatch, tmp_path, prefix_title) + _stub_changed_files(monkeypatch, ["src/api/routes.py"]) + + assert ctp.main() == 1 + err = capsys.readouterr().out + assert "::error::" in err + assert "Testing Policy" in err + + +@pytest.mark.parametrize( + "prefix_title", ["chore: x", "docs: y", "refactor: z", "test: a", "release: b"] +) +def test_main_warn_only_prefixes_pass( + prefix_title: str, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("GITHUB_BASE_REF", "develop") + _set_event_payload(monkeypatch, tmp_path, prefix_title) + _stub_changed_files(monkeypatch, ["src/api/routes.py"]) + + assert ctp.main() == 0 + out = capsys.readouterr().out + assert "::warning::" in out + + +def test_main_unknown_prefix_warns( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """No conventional prefix → warn-only (Lint PR title is the prefix gate).""" + monkeypatch.setenv("GITHUB_BASE_REF", "develop") + _set_event_payload(monkeypatch, tmp_path, "no prefix at all") + _stub_changed_files(monkeypatch, ["src/api/routes.py"]) + + assert ctp.main() == 0 + out = capsys.readouterr().out + assert "prefix not recognised" in out + + +def test_main_excludes_non_python_src_changes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Non-Python files under src/ shouldn't trigger the gate.""" + monkeypatch.setenv("GITHUB_BASE_REF", "develop") + _set_event_payload(monkeypatch, tmp_path, "feat: copy a fixture") + _stub_changed_files(monkeypatch, ["src/api/README.md", "src/data/sample.csv"]) + + assert ctp.main() == 0 + assert "gate not applicable" in capsys.readouterr().out + + +def test_main_no_base_ref_skips( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.delenv("GITHUB_BASE_REF", raising=False) + monkeypatch.delenv("GITHUB_EVENT_PATH", raising=False) + assert ctp.main() == 0 + assert "skipping" in capsys.readouterr().out + + +def test_main_git_failure_exits_2( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("GITHUB_BASE_REF", "develop") + _set_event_payload(monkeypatch, tmp_path, "feat: x") + + def boom(_base_ref: str) -> list[str]: + msg = "git fetch failed" + raise RuntimeError(msg) + + monkeypatch.setattr(ctp, "changed_files", boom) + assert ctp.main() == 2 + assert "::error::" in capsys.readouterr().out diff --git a/tests/test_check_version_bump.py b/tests/test_check_version_bump.py new file mode 100644 index 0000000..a1d8dc6 --- /dev/null +++ b/tests/test_check_version_bump.py @@ -0,0 +1,241 @@ +"""Tests for `.github/scripts/check_version_bump.py`. + +Covers the parsing helpers and end-to-end `main()` behaviour by stubbing +the `git show` subprocess call and the GitHub Actions event payload. +""" + +from __future__ import annotations + +import importlib.util +import json +import os +import sys +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pytest + +if TYPE_CHECKING: + from collections.abc import Iterator + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPT_PATH = REPO_ROOT / ".github" / "scripts" / "check_version_bump.py" + + +def _load_script() -> Any: + """Load the check_version_bump module by file path (it lives outside src/).""" + spec = importlib.util.spec_from_file_location("check_version_bump", SCRIPT_PATH) + if spec is None or spec.loader is None: + msg = f"Could not load script at {SCRIPT_PATH}" + raise RuntimeError(msg) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +cvb = _load_script() + + +# ---------- pyproject_version ---------- + + +def test_pyproject_version_extracts_string() -> None: + text = '[project]\nname = "harness-python-react"\nversion = "1.6.5"\n' + assert cvb.pyproject_version(text) == "1.6.5" + + +def test_pyproject_version_missing_raises() -> None: + with pytest.raises(ValueError, match="version not found"): + cvb.pyproject_version('[project]\nname = "harness-python-react"\n') + + +def test_pyproject_version_empty_raises() -> None: + with pytest.raises(ValueError, match="version not found"): + cvb.pyproject_version('[project]\nversion = ""\n') + + +# ---------- uv_lock_self_version ---------- + + +def test_uv_lock_self_version_extracts() -> None: + lock = ( + '[[package]]\nname = "annotated-doc"\nversion = "0.0.4"\n\n' + '[[package]]\nname = "harness-python-react"\nversion = "1.6.5"\n' + 'source = { editable = "." }\n' + ) + assert cvb.uv_lock_self_version(lock) == "1.6.5" + + +def test_uv_lock_self_version_missing_raises() -> None: + with pytest.raises(ValueError, match="harness-python-react"): + cvb.uv_lock_self_version('[[package]]\nname = "fastapi"\nversion = "0.100"\n') + + +# ---------- is_release_pr ---------- + + +@pytest.mark.parametrize( + "title,expected", + [ + ("release: v1.6.5 — harness rollout", True), + ("Release: v1.6.5", True), # leading-cap accepted + ("RELEASE: yes", True), + (" release: leading-spaces", True), + ("feat: add a thing", False), + ("fix: something", False), + ("chore: release notes update", False), # 'release' substring, wrong prefix + ("", False), + (None, False), + ], +) +def test_is_release_pr(title: str | None, expected: bool) -> None: + assert cvb.is_release_pr(title) is expected + + +# ---------- main() end-to-end ---------- + + +@pytest.fixture +def fake_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]: + """Set up a fake repo root with pyproject.toml + uv.lock and chdir into it.""" + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "harness-python-react"\nversion = "1.6.6"\n', + encoding="utf-8", + ) + (tmp_path / "uv.lock").write_text( + '[[package]]\nname = "harness-python-react"\nversion = "1.6.6"\n' + 'source = { editable = "." }\n', + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + yield tmp_path + + +def _set_event_payload( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, title: str +) -> None: + event_file = tmp_path / "event.json" + event_file.write_text( + json.dumps({"pull_request": {"title": title}}), encoding="utf-8" + ) + monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file)) + + +def _stub_git_show(monkeypatch: pytest.MonkeyPatch, base_pyproject: str) -> None: + def fake(path: Path, base_ref: str) -> str: + assert str(path) == "pyproject.toml" + assert base_ref == "develop" + return base_pyproject + + monkeypatch.setattr(cvb, "git_show_at_base", fake) + + +def test_main_bumped_pass( + fake_repo: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setenv("GITHUB_BASE_REF", "develop") + _set_event_payload(monkeypatch, fake_repo, "feat: add thing") + _stub_git_show( + monkeypatch, '[project]\nname = "harness-python-react"\nversion = "1.6.5"\n' + ) + + assert cvb.main() == 0 + out = capsys.readouterr().out + assert "Version bumped: 1.6.5 -> 1.6.6" in out + + +def test_main_unchanged_fails( + fake_repo: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setenv("GITHUB_BASE_REF", "develop") + _set_event_payload(monkeypatch, fake_repo, "feat: forgot to bump") + _stub_git_show( + monkeypatch, '[project]\nname = "harness-python-react"\nversion = "1.6.6"\n' + ) + + assert cvb.main() == 1 + err_out = capsys.readouterr().out + assert "::error::" in err_out + assert "unchanged from develop" in err_out + + +def test_main_release_prefix_exempt( + fake_repo: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setenv("GITHUB_BASE_REF", "main") + _set_event_payload(monkeypatch, fake_repo, "release: v1.6.6 — harness") + # Even though pyproject == base, release: PRs short-circuit before we + # inspect git history. + _stub_git_show( + monkeypatch, '[project]\nname = "harness-python-react"\nversion = "1.6.6"\n' + ) + + assert cvb.main() == 0 + assert "release: PR" in capsys.readouterr().out + + +def test_main_uv_lock_mismatch_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "harness-python-react"\nversion = "1.6.6"\n', + encoding="utf-8", + ) + (tmp_path / "uv.lock").write_text( + '[[package]]\nname = "harness-python-react"\nversion = "1.6.5"\n' # stale! + 'source = { editable = "." }\n', + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GITHUB_BASE_REF", "develop") + _set_event_payload(monkeypatch, tmp_path, "feat: stale lock") + _stub_git_show( + monkeypatch, '[project]\nname = "harness-python-react"\nversion = "1.6.5"\n' + ) + + assert cvb.main() == 1 + err = capsys.readouterr().out + assert "self-version disagree" in err + + +def test_main_no_base_ref_skips( + fake_repo: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + # Outside a PR run there is no GITHUB_BASE_REF; the gate degrades green. + monkeypatch.delenv("GITHUB_BASE_REF", raising=False) + monkeypatch.delenv("GITHUB_EVENT_PATH", raising=False) + assert cvb.main() == 0 + assert "skipping" in capsys.readouterr().out + + +def test_main_parse_error_exits_2( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "harness-python-react"\n# version missing\n', + encoding="utf-8", + ) + (tmp_path / "uv.lock").write_text( + '[[package]]\nname = "harness-python-react"\nversion = "1.6.6"\n', + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GITHUB_BASE_REF", "develop") + _set_event_payload(monkeypatch, tmp_path, "feat: x") + _stub_git_show( + monkeypatch, '[project]\nname = "harness-python-react"\nversion = "1.6.5"\n' + ) + + assert cvb.main() == 2 + assert "::error::" in capsys.readouterr().out + + +# Ensure the loader didn't accidentally leave os.environ polluted. +def test_environ_unaffected_by_loader() -> None: + # Sanity: nothing the script does at import time touches os.environ. + assert "CHECK_VERSION_BUMP_LOADED" not in os.environ diff --git a/uv.lock b/uv.lock index b6c0c1e..9ca9a26 100644 --- a/uv.lock +++ b/uv.lock @@ -328,7 +328,7 @@ wheels = [ [[package]] name = "harness-python-react" -version = "0.1.0" +version = "0.2.0" source = { virtual = "." } dependencies = [ { name = "fastapi" },