diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..733826c --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,9 @@ +* @cigan1 + +# Require focused review for high-risk areas. +/.github/workflows/ @cigan1 +/.github/scripts/ @cigan1 +/SECURITY.md @cigan1 +/MAINTAINERS.md @cigan1 +/pyproject.toml @cigan1 +/src/siftfy/ @cigan1 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..83d94b7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,20 @@ +name: Bug report +description: Report a reproducible problem +body: + - type: textarea + id: description + attributes: + label: Description + description: What happened? + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..9ff437b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,14 @@ +name: Feature request +description: Suggest an improvement +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What problem would this solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposal diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..68a36c8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..1cd321e --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +## Summary + +## Testing + +List the commands you ran and any checks you could not run. + +## Checklist + +- [ ] Tests added or updated +- [ ] Documentation updated when needed +- [ ] Security or compatibility impact considered +- [ ] License, governance, or support impact considered +- [ ] No credentials, private data, or non-public operational details added diff --git a/.github/scripts/license_metadata_audit.py b/.github/scripts/license_metadata_audit.py new file mode 100644 index 0000000..f04c544 --- /dev/null +++ b/.github/scripts/license_metadata_audit.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +# ruff: noqa: E501,I001,UP035 +"""Audit open-source license files and package metadata.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import tomllib +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Iterable + + +LICENSE_FILES = ("LICENSE", "LICENSE.md", "COPYING", "COPYING.md") +COMMON_SPDX = { + "0BSD", + "AGPL-3.0-only", + "AGPL-3.0-or-later", + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-2.0-or-later", + "GPL-3.0-only", + "GPL-3.0-or-later", + "ISC", + "LGPL-2.1-only", + "LGPL-2.1-or-later", + "LGPL-3.0-only", + "LGPL-3.0-or-later", + "MIT", + "MPL-2.0", + "Unlicense", +} + +SPDX_TOKEN = re.compile(r"^[A-Za-z0-9-.+]+$") +LICENSE_EXPR = re.compile(r"^[A-Za-z0-9-.+() ]+(?:\s+(?:AND|OR|WITH)\s+[A-Za-z0-9-.+() ]+)*$") + + +@dataclass +class Finding: + severity: str + check: str + path: str + message: str + + +def license_files(root: Path) -> list[Path]: + return [root / name for name in LICENSE_FILES if (root / name).exists()] + + +def classify_license_value(value: object) -> tuple[str, str]: + if not isinstance(value, str): + return "blocker", "missing or invalid license metadata" + value = value.strip() + if not value or value.upper() in {"TODO", "TBD", "UNKNOWN"}: + return "blocker", "missing or invalid license metadata" + if value in COMMON_SPDX: + return "ok", f"license metadata present: {value}" + if SPDX_TOKEN.match(value) or LICENSE_EXPR.match(value): + return "warn", f"unrecognized license expression; verify SPDX identifier before release: {value}" + return "blocker", "missing or invalid license metadata" + + +def read_json(path: Path) -> dict[str, object] | None: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + return data if isinstance(data, dict) else None + + +def read_toml(path: Path) -> dict[str, object] | None: + try: + data = tomllib.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + return data if isinstance(data, dict) else None + + +def add_license_value(findings: list[Finding], check: str, path: str, value: object) -> None: + severity, message = classify_license_value(value) + findings.append(Finding(severity, check, path, message)) + + +def audit_package_json(root: Path, findings: list[Finding]) -> None: + path = root / "package.json" + if not path.exists(): + return + data = read_json(path) + if data is None: + findings.append(Finding("blocker", "package_json_parse", "package.json", "package.json is not valid JSON")) + return + add_license_value(findings, "package_license", "package.json", data.get("license")) + for key in ("name", "description", "repository"): + if data.get(key): + findings.append(Finding("ok", f"package_{key}", "package.json", f"{key} present")) + else: + findings.append(Finding("warn", f"package_{key}", "package.json", f"missing {key} metadata")) + + +def audit_pyproject(root: Path, findings: list[Finding]) -> None: + path = root / "pyproject.toml" + if not path.exists(): + return + data = read_toml(path) + if data is None: + findings.append(Finding("blocker", "pyproject_parse", "pyproject.toml", "pyproject.toml is not valid TOML")) + return + project = data.get("project") + poetry = data.get("tool", {}) + poetry_project = poetry.get("poetry", {}) if isinstance(poetry, dict) else {} + if isinstance(project, dict): + license_value = project.get("license") + if isinstance(license_value, dict): + license_value = license_value.get("text") or license_value.get("file") + add_license_value(findings, "pyproject_license", "pyproject.toml", license_value) + elif isinstance(poetry_project, dict): + add_license_value(findings, "poetry_license", "pyproject.toml", poetry_project.get("license")) + else: + findings.append(Finding("warn", "pyproject_project", "pyproject.toml", "no [project] or [tool.poetry] metadata detected")) + + +def audit_cargo(root: Path, findings: list[Finding]) -> None: + path = root / "Cargo.toml" + if not path.exists(): + return + data = read_toml(path) + if data is None: + findings.append(Finding("blocker", "cargo_parse", "Cargo.toml", "Cargo.toml is not valid TOML")) + return + package = data.get("package") + if isinstance(package, dict): + add_license_value(findings, "cargo_license", "Cargo.toml", package.get("license")) + if not package.get("description"): + findings.append(Finding("warn", "cargo_description", "Cargo.toml", "missing package description")) + else: + findings.append(Finding("warn", "cargo_package", "Cargo.toml", "no [package] metadata detected")) + + +def audit_composer(root: Path, findings: list[Finding]) -> None: + path = root / "composer.json" + if not path.exists(): + return + data = read_json(path) + if data is None: + findings.append(Finding("blocker", "composer_parse", "composer.json", "composer.json is not valid JSON")) + return + license_value = data.get("license") + if isinstance(license_value, list): + severities = [classify_license_value(item)[0] for item in license_value] + if "blocker" in severities: + findings.append(Finding("blocker", "composer_license", "composer.json", "missing or invalid license metadata")) + elif "warn" in severities: + findings.append(Finding("warn", "composer_license", "composer.json", "unrecognized license expression; verify SPDX identifiers before release")) + else: + findings.append(Finding("ok", "composer_license", "composer.json", "license metadata present")) + else: + add_license_value(findings, "composer_license", "composer.json", license_value) + + +def audit_gemspec(root: Path, findings: list[Finding]) -> None: + for path in root.glob("*.gemspec"): + text = path.read_text(encoding="utf-8", errors="ignore") + if re.search(r"\.licenses?\s*=\s*\[?['\"][A-Za-z0-9-.+]+['\"]", text): + findings.append(Finding("ok", "gemspec_license", path.name, "license metadata present")) + else: + findings.append(Finding("blocker", "gemspec_license", path.name, "missing license metadata")) + + +def audit(root: Path) -> dict[str, object]: + root = root.resolve() + findings: list[Finding] = [] + + found_license_files = license_files(root) + if found_license_files: + for path in found_license_files: + rel = str(path.relative_to(root)) + text = path.read_text(encoding="utf-8", errors="ignore") + if re.search(r"\b(todo|tbd|choose a license|add license)\b", text, re.I): + findings.append(Finding("blocker", "license_placeholder", rel, "license file contains unresolved placeholder text")) + elif len(text.strip()) < 100: + findings.append(Finding("warn", "license_file", rel, "license file is unusually short; verify official text")) + else: + findings.append(Finding("ok", "license_file", rel, "license file present")) + else: + findings.append(Finding("blocker", "license_file", ".", "missing license file")) + + audit_package_json(root, findings) + audit_pyproject(root, findings) + audit_cargo(root, findings) + audit_composer(root, findings) + audit_gemspec(root, findings) + + if not any((root / name).exists() for name in ("package.json", "pyproject.toml", "Cargo.toml", "composer.json")) and not list(root.glob("*.gemspec")): + findings.append(Finding("info", "package_metadata", ".", "no recognized package metadata found")) + + return report(root, findings) + + +def report(root: Path, findings: Iterable[Finding]) -> dict[str, object]: + finding_list = list(findings) + counts: dict[str, int] = {} + for finding in finding_list: + counts[finding.severity] = counts.get(finding.severity, 0) + 1 + decision = "no-go" if counts.get("blocker") else "conditional" if counts.get("warn") else "go" + return { + "root": str(root), + "decision": decision, + "counts": counts, + "findings": [asdict(finding) for finding in finding_list], + } + + +def print_human(data: dict[str, object]) -> None: + print(f"License metadata readiness: {data['decision']}") + print(f"Repository: {data['root']}") + print(f"Counts: {data['counts']}") + print() + for finding in data["findings"]: # type: ignore[index] + print(f"[{finding['severity']}] {finding['check']} {finding['path']} - {finding['message']}") + + +def should_fail(data: dict[str, object], fail_on: str) -> bool: + counts = data["counts"] # type: ignore[assignment] + if fail_on == "none": + return False + if fail_on == "warn": + return bool(counts.get("blocker") or counts.get("warn")) # type: ignore[union-attr] + return bool(counts.get("blocker")) # type: ignore[union-attr] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("repo", nargs="?", default=".", help="repository path") + parser.add_argument("--json", action="store_true", help="emit JSON") + parser.add_argument("--fail-on", choices=("none", "blocker", "warn"), default="none", help="exit nonzero at this severity") + args = parser.parse_args() + + data = audit(Path(args.repo)) + if args.json: + print(json.dumps(data, indent=2, sort_keys=True)) + else: + print_human(data) + return 1 if should_fail(data, args.fail_on) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/readme_audit.py b/.github/scripts/readme_audit.py new file mode 100644 index 0000000..d80bc5b --- /dev/null +++ b/.github/scripts/readme_audit.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +# ruff: noqa: E501,I001,UP035 +"""Audit README quality for first-user open-source readiness.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Iterable + + +README_NAMES = ("README.md", "README.rst", "README") + +PLACEHOLDERS = [ + "one-sentence description", + "short project description", + "add the fastest working install command", + "add ecosystem-specific install command", + "add setup commands", + "describe whether this project is", + "describe the visible output", + "add an exact official license", + "todo", + "tbd", +] + +REQUIRED_SIGNALS = [ + ("purpose", re.compile(r"\b(what this project|overview|purpose|about|does|is a|is an)\b", re.I), "explain what the project is"), + ("problem", re.compile(r"\b(problem|solves|why|use case|pain|workflow|for teams|for developers)\b", re.I), "explain what problem it solves"), + ("status", re.compile(r"\b(status|stable|experimental|production|preview|archived|alpha|beta)\b", re.I), "document project status"), + ("requirements", re.compile(r"\b(requirements|prerequisites|dependencies|supported|compatibility|platform)\b", re.I), "document prerequisites and compatibility"), + ("install", re.compile(r"\b(install|installation|setup)\b", re.I), "document installation"), + ("quickstart", re.compile(r"\b(quickstart|quick start|get started|getting started)\b", re.I), "document the shortest working path"), + ("usage", re.compile(r"\b(usage|example|examples|api|cli)\b", re.I), "show a minimal real usage example"), + ("expected_result", re.compile(r"\b(expected|output|result|you should see|localhost|http://|https://)\b", re.I), "describe expected output or behavior"), + ("development", re.compile(r"\b(development|contributing|test|tests|lint|build)\b", re.I), "document development validation commands"), + ("support", re.compile(r"\b(support|questions|issues|bugs|feature requests|discussion)\b", re.I), "document support and issue paths"), + ("security", re.compile(r"\b(security|vulnerability|vulnerabilities|disclosure)\b", re.I), "document vulnerability reporting or link SECURITY.md"), + ("license", re.compile(r"\b(license|licence|spdx)\b", re.I), "document license"), +] + + +@dataclass +class Finding: + severity: str + check: str + path: str + message: str + + +def find_readme(root: Path) -> Path | None: + for name in README_NAMES: + path = root / name + if path.exists(): + return path + return None + + +def line_number(text: str, needle: str) -> int: + offset = text.lower().find(needle.lower()) + if offset < 0: + return 1 + return text[:offset].count("\n") + 1 + + +def has_code_block(text: str) -> bool: + return "```" in text or "::\n" in text + + +def has_shell_command(text: str) -> bool: + return bool(re.search(r"(?m)^\s*(?:\$ |npm |pnpm |yarn |python|pip |go |cargo |make |docker |git clone|curl )", text)) + + +def audit(root: Path) -> dict[str, object]: + root = root.resolve() + findings: list[Finding] = [] + readme = find_readme(root) + if not readme: + findings.append(Finding("blocker", "readme", ".", "missing README")) + return report(root, findings) + + rel = str(readme.relative_to(root)) + text = readme.read_text(encoding="utf-8", errors="ignore") + lower = text.lower() + + if len(text.strip()) < 600: + findings.append(Finding("warn", "readme_depth", rel, "README is very short for a public project")) + else: + findings.append(Finding("ok", "readme_depth", rel, "README has substantive content")) + + title_match = re.search(r"(?m)^\s*#\s+\S+", text) + if title_match: + findings.append(Finding("ok", "title", f"{rel}:{text[:title_match.start()].count(chr(10)) + 1}", "top-level title present")) + else: + findings.append(Finding("warn", "title", rel, "missing top-level project title")) + + for phrase in PLACEHOLDERS: + if re.search(rf"\b{re.escape(phrase)}\b", lower): + findings.append(Finding("blocker", "placeholder", f"{rel}:{line_number(text, phrase)}", f"unresolved placeholder: {phrase}")) + + for check, pattern, message in REQUIRED_SIGNALS: + if pattern.search(text): + findings.append(Finding("ok", check, rel, "present")) + else: + severity = "blocker" if check in {"purpose", "install", "quickstart", "license"} else "warn" + findings.append(Finding(severity, check, rel, message)) + + if has_code_block(text): + findings.append(Finding("ok", "examples", rel, "code block present")) + else: + findings.append(Finding("warn", "examples", rel, "README should include copy-pasteable commands or examples")) + + if has_shell_command(text): + findings.append(Finding("ok", "commands", rel, "command-like setup or usage examples present")) + else: + findings.append(Finding("warn", "commands", rel, "README should include concrete commands")) + + if (root / "SECURITY.md").exists() and "security" not in lower: + findings.append(Finding("warn", "security_link", rel, "README should link or mention SECURITY.md")) + + return report(root, findings) + + +def report(root: Path, findings: Iterable[Finding]) -> dict[str, object]: + finding_list = list(findings) + counts: dict[str, int] = {} + for finding in finding_list: + counts[finding.severity] = counts.get(finding.severity, 0) + 1 + decision = "no-go" if counts.get("blocker") else "conditional" if counts.get("warn") else "go" + return { + "root": str(root), + "decision": decision, + "counts": counts, + "findings": [asdict(finding) for finding in finding_list], + } + + +def print_human(data: dict[str, object]) -> None: + print(f"README readiness: {data['decision']}") + print(f"Repository: {data['root']}") + print(f"Counts: {data['counts']}") + print() + for finding in data["findings"]: # type: ignore[index] + print(f"[{finding['severity']}] {finding['check']} {finding['path']} - {finding['message']}") + + +def should_fail(data: dict[str, object], fail_on: str) -> bool: + counts = data["counts"] # type: ignore[assignment] + if fail_on == "none": + return False + if fail_on == "warn": + return bool(counts.get("blocker") or counts.get("warn")) # type: ignore[union-attr] + return bool(counts.get("blocker")) # type: ignore[union-attr] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("repo", nargs="?", default=".", help="repository path") + parser.add_argument("--json", action="store_true", help="emit JSON") + parser.add_argument("--fail-on", choices=("none", "blocker", "warn"), default="none", help="exit nonzero at this severity") + args = parser.parse_args() + + data = audit(Path(args.repo)) + if args.json: + print(json.dumps(data, indent=2, sort_keys=True)) + else: + print_human(data) + return 1 if should_fail(data, args.fail_on) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/workflow_audit.py b/.github/scripts/workflow_audit.py new file mode 100644 index 0000000..c5d4244 --- /dev/null +++ b/.github/scripts/workflow_audit.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +# ruff: noqa: E501,I001,UP035 +"""Audit GitHub Actions workflow safety for open-source pull requests.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Iterable + + +WORKFLOW_GLOBS = ("*.yml", "*.yaml") +DANGEROUS_PR_TERMS = re.compile(r"\b(deploy|deployment|publish|sign|attestation|provenance|upload-artifact|push image|docker push)\b", re.I) +WRITE_PERMISSION = re.compile(r"(?m)^\s*[a-z-]+:\s*write\s*$") + + +@dataclass +class Finding: + severity: str + check: str + path: str + message: str + + +def workflow_files(root: Path) -> list[Path]: + workflows = root / ".github" / "workflows" + if not workflows.exists(): + return [] + files: list[Path] = [] + for pattern in WORKFLOW_GLOBS: + files.extend(workflows.glob(pattern)) + return sorted(files) + + +def has_trigger(text: str, trigger: str) -> bool: + return bool(re.search(rf"(?m)^\s*{re.escape(trigger)}\s*:", text) or re.search(rf"(?m)^\s*-\s*{re.escape(trigger)}\s*$", text)) + + +def workflow_name(text: str, fallback: str) -> str: + match = re.search(r"(?m)^\s*name:\s*(.+?)\s*$", text) + return match.group(1).strip("'\"") if match else fallback + + +def third_party_uses(text: str) -> list[str]: + uses = re.findall(r"(?m)^\s*uses:\s*([^@\s]+)@([^\s]+)", text) + risky: list[str] = [] + for action, ref in uses: + if action.startswith("./") or action.startswith("actions/"): + continue + if not re.fullmatch(r"[a-f0-9]{40}", ref): + risky.append(f"{action}@{ref}") + return risky + + +def audit(root: Path) -> dict[str, object]: + root = root.resolve() + findings: list[Finding] = [] + workflows = workflow_files(root) + if not workflows: + findings.append(Finding("blocker", "workflows", ".", "missing .github/workflows CI configuration")) + return report(root, findings) + + pr_workflow_count = 0 + compliance_like = False + + for path in workflows: + rel = str(path.relative_to(root)) + text = path.read_text(encoding="utf-8", errors="ignore") + lower = text.lower() + name = workflow_name(text, rel).lower() + has_pr = has_trigger(text, "pull_request") + has_pr_target = has_trigger(text, "pull_request_target") + if has_pr: + pr_workflow_count += 1 + if any(term in name or term in lower for term in ("compliance", "readiness", "security", "license")): + compliance_like = True + + if "permissions:" in text: + findings.append(Finding("ok", "permissions_declared", rel, "explicit permissions present")) + else: + findings.append(Finding("blocker", "permissions_declared", rel, "missing explicit least-privilege permissions")) + + if re.search(r"(?m)^\s*permissions:\s*write-all\s*$", text): + findings.append(Finding("blocker", "permissions_write_all", rel, "uses permissions: write-all")) + + if has_pr_target: + findings.append(Finding("blocker", "pull_request_target", rel, "pull_request_target requires maintainer-reviewed justification")) + + if has_pr and "secrets." in text: + findings.append(Finding("blocker", "pr_secrets", rel, "pull_request workflow references secrets")) + + if has_pr and DANGEROUS_PR_TERMS.search(text): + findings.append(Finding("blocker", "pr_release_or_deploy", rel, "pull_request workflow appears to publish, release, sign, upload, or deploy")) + + if has_pr and WRITE_PERMISSION.search(text): + findings.append(Finding("warn", "pr_write_permissions", rel, "pull_request workflow requests write permission; verify this is necessary and fork-safe")) + + if "concurrency:" in text: + findings.append(Finding("ok", "concurrency", rel, "concurrency control present")) + else: + findings.append(Finding("warn", "concurrency", rel, "missing concurrency cancellation")) + + unpinned = third_party_uses(text) + for action in unpinned: + findings.append(Finding("warn", "unpinned_third_party_action", rel, f"third-party action is not pinned to a full SHA: {action}")) + + if pr_workflow_count: + findings.append(Finding("ok", "pull_request_checks", ".github/workflows", f"{pr_workflow_count} pull_request workflow(s) detected")) + else: + findings.append(Finding("blocker", "pull_request_checks", ".github/workflows", "no pull_request validation workflow detected")) + + if compliance_like: + findings.append(Finding("ok", "compliance_workflow", ".github/workflows", "compliance/security/license workflow detected")) + else: + findings.append(Finding("warn", "compliance_workflow", ".github/workflows", "add compliance checks for README, license, workflows, secrets, and required files")) + + codeowners = root / ".github" / "CODEOWNERS" + if codeowners.exists() or (root / "CODEOWNERS").exists(): + findings.append(Finding("ok", "codeowners", "CODEOWNERS", "CODEOWNERS present")) + else: + findings.append(Finding("warn", "codeowners", ".", "missing CODEOWNERS for sensitive path review")) + + return report(root, findings) + + +def report(root: Path, findings: Iterable[Finding]) -> dict[str, object]: + finding_list = list(findings) + counts: dict[str, int] = {} + for finding in finding_list: + counts[finding.severity] = counts.get(finding.severity, 0) + 1 + decision = "no-go" if counts.get("blocker") else "conditional" if counts.get("warn") else "go" + return { + "root": str(root), + "decision": decision, + "counts": counts, + "findings": [asdict(finding) for finding in finding_list], + } + + +def print_human(data: dict[str, object]) -> None: + print(f"Workflow readiness: {data['decision']}") + print(f"Repository: {data['root']}") + print(f"Counts: {data['counts']}") + print() + for finding in data["findings"]: # type: ignore[index] + print(f"[{finding['severity']}] {finding['check']} {finding['path']} - {finding['message']}") + + +def should_fail(data: dict[str, object], fail_on: str) -> bool: + counts = data["counts"] # type: ignore[assignment] + if fail_on == "none": + return False + if fail_on == "warn": + return bool(counts.get("blocker") or counts.get("warn")) # type: ignore[union-attr] + return bool(counts.get("blocker")) # type: ignore[union-attr] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("repo", nargs="?", default=".", help="repository path") + parser.add_argument("--json", action="store_true", help="emit JSON") + parser.add_argument("--fail-on", choices=("none", "blocker", "warn"), default="none", help="exit nonzero at this severity") + args = parser.parse_args() + + data = audit(Path(args.repo)) + if args.json: + print(json.dumps(data, indent=2, sort_keys=True)) + else: + print_human(data) + return 1 if should_fail(data, args.fail_on) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c01501f..c07aa19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,13 @@ on: pull_request: branches: [main] +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-latest @@ -36,3 +43,6 @@ jobs: - name: Test run: pytest -q + + - name: Build package + run: python -m build diff --git a/.github/workflows/oss-compliance.yml b/.github/workflows/oss-compliance.yml new file mode 100644 index 0000000..4913b3b --- /dev/null +++ b/.github/workflows/oss-compliance.yml @@ -0,0 +1,30 @@ +name: OSS Compliance + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + compliance: + name: Repository compliance + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Check open source readiness gates + shell: bash + run: | + set -euo pipefail + python3 .github/scripts/readme_audit.py . --fail-on warn + python3 .github/scripts/license_metadata_audit.py . --fail-on warn + python3 .github/scripts/workflow_audit.py . --fail-on blocker diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9b0f74a..a802ff6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,6 +9,10 @@ on: tags: - "v*" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: publish: runs-on: ubuntu-latest @@ -43,4 +47,4 @@ jobs: run: python -m build - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b1c8b8a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes to this project are documented here. + +## Unreleased + +- Add README links to runnable framework examples and free anti-spam tools. +- Add package metadata links for examples, tools, and pricing. +- Harden GitHub Actions permissions and add OSS readiness files. + +## 0.1.1 - 2026-05-04 + +- Validate PyPI Trusted Publisher release configuration. +- Bump package version to 0.1.1. + +## 0.1.0 - 2026-05-03 + +- Initial release of the typed synchronous and asynchronous Siftfy Python client. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..d64a33f --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,10 @@ +# Code of Conduct + +This project follows the Contributor Covenant Code of Conduct, version 2.1. + +The official policy text is maintained by the Contributor Covenant project: +. + +Report unacceptable behavior privately to . Maintainers may remove, +edit, or reject comments, commits, issues, and pull requests that do not align +with that policy. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3d605fa --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,38 @@ +# Contributing to siftfy-python + +Thanks for helping improve the Siftfy Python SDK. This repository is a small, +typed Python client for the Siftfy spam-classification API, so changes should +stay focused on SDK behavior, packaging, tests, or public documentation. + +## Development Setup + +Use Python 3.9 or newer. + +```bash +python -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +pip install -e ".[dev]" +``` + +Run the same checks that CI runs before opening a pull request: + +```bash +ruff check src tests +mypy src +pytest -q +python -m build +``` + +## Pull Requests + +- Keep pull requests focused on one behavior or documentation change. +- Add or update tests for any client behavior change. +- Update README examples or package metadata when user-facing behavior changes. +- Avoid committing generated caches, local virtual environments, credentials, or + service-specific test data. + +## Contributor Certificate + +By contributing, you agree that your contribution is licensed under the MIT +License in this repository. diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..8d9793d --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,5 @@ +# Maintainers + +- @cigan1: SDK implementation, packaging, documentation, and release automation. + +Security reports should be sent to , not posted publicly. diff --git a/README.md b/README.md index af052c2..1b0496d 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,10 @@ queues, no models to host. [![CI](https://github.com/siftfy/siftfy-python/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/siftfy/siftfy-python/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +## Requirements + +Siftfy supports Python 3.9 and newer. + ## Install ```bash @@ -30,6 +34,19 @@ print(result.likelihood) # "high" Get an API key at [siftfy.io](https://siftfy.io) — the free tier covers 10,000 requests/month at no cost. +## Runnable examples + +Use these when wiring Siftfy into a real form, signup flow, or moderation +queue: + +- [FastAPI contact-form spam filter](https://siftfy.io/examples/fastapi-spam-filter) +- [Next.js route handler](https://siftfy.io/examples/nextjs-spam-filter) +- [Django view](https://siftfy.io/examples/django-spam-filter) +- [Laravel controller](https://siftfy.io/examples/laravel-spam-filter) +- [Webflow Worker](https://siftfy.io/examples/webflow-worker-spam-filter) +- [Ghost webhook pattern](https://siftfy.io/examples/ghost-spam-filter) +- [Browser spam probability tester](https://siftfy.io/tools/spam-probability-tester) + ## Async ```python @@ -96,9 +113,34 @@ You can also pass your own `httpx.Client` (or `httpx.AsyncClient` for the async client) via `http_client=...` if you want connection pooling, custom transports, or to share a client across services. +## Development + +```bash +python -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +pip install -e ".[dev]" + +ruff check src tests +mypy src +pytest -q +python -m build +``` + +## Support and security + +Use [GitHub Issues](https://github.com/siftfy/siftfy-python/issues) for SDK bugs +and feature requests. Do not open public issues for suspected vulnerabilities; +report them privately using the process in [SECURITY.md](SECURITY.md). + ## Resources - API reference: +- Predict endpoint: +- Runnable examples: +- Free anti-spam tools: +- Contact-form guide: +- Comparison guide: - Pricing: - Status: ping `https://api.siftfy.io/health` - Issues: diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..0da7aa0 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +## Supported Versions + +Security fixes are made on the latest released version of `siftfy`. + +## Reporting a Vulnerability + +Do not open a public issue for a suspected vulnerability. Email + with: + +- A description of the issue and affected versions. +- Steps to reproduce or a minimal proof of concept. +- Any known impact or mitigations. + +Use the subject line `Security: siftfy-python` so the report can be triaged +quickly. + +## Scope + +Reports about the Python SDK, packaging, examples, or release automation belong +in this repository. Vulnerabilities in the hosted Siftfy API should also be sent +to ; include the affected endpoint and request ID when available. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..5a29a31 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,11 @@ +# Support + +Use GitHub Issues for reproducible SDK bugs, documentation problems, and feature +requests: + + + +For account, billing, API key, hosted API availability, or security questions, +contact . This repository does not provide support for unrelated +spam-filter implementations, third-party framework behavior, or private +application code. diff --git a/pyproject.toml b/pyproject.toml index 0893434..cab3a65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,9 +16,15 @@ keywords = [ "spam-detection", "spam-classification", "spam-classifier", + "spam-filter", + "antispam", "moderation", "content-moderation", + "text-classification", + "contact-forms", "api-client", + "fastapi", + "django", "siftfy", ] classifiers = [ @@ -45,12 +51,16 @@ dependencies = [ [project.urls] Homepage = "https://siftfy.io" Documentation = "https://siftfy.io/docs" +Examples = "https://siftfy.io/examples" +Tools = "https://siftfy.io/tools" +Pricing = "https://siftfy.io/pricing" Source = "https://github.com/siftfy/siftfy-python" Issues = "https://github.com/siftfy/siftfy-python/issues" Changelog = "https://github.com/siftfy/siftfy-python/releases" [project.optional-dependencies] dev = [ + "build>=1.2", "pytest>=8", "pytest-asyncio>=0.23", "respx>=0.21",