From a87c79a61f45ca02b3fec3a1840b2e16a27b8b4f Mon Sep 17 00:00:00 2001 From: Smarter Harder <33955773+NWarila@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:15:31 -0400 Subject: [PATCH] ci: wire org governance gates Vendor the docs layout, ADR schema, and attribution residue gates from NWarila/.github. Pin repo-hygiene, CodeQL, and Scorecard reusables to NWarila/.github@5da716c68c90a86465d774f553d2f7161b60d616. Skip Scorecard on pull_request; it runs on push, schedule, branch_protection_rule, and workflow_dispatch because it needs id-token and security-events scopes. Defer the baseline manifest gate until this repo owns baseline-manifest.json. --- .github/workflows/template-ci.yml | 79 +++++++- sync-manifest.json | 3 + tools/check_adr_schema.py | 295 ++++++++++++++++++++++++++++++ tools/check_ai_residue.py | 96 ++++++++++ tools/check_docs_layout.py | 68 +++++++ tools/check_pin_parity.py | 119 ++++++++++++ 6 files changed, 659 insertions(+), 1 deletion(-) create mode 100644 tools/check_adr_schema.py create mode 100644 tools/check_ai_residue.py create mode 100644 tools/check_docs_layout.py create mode 100644 tools/check_pin_parity.py diff --git a/.github/workflows/template-ci.yml b/.github/workflows/template-ci.yml index dbef263..9a82085 100644 --- a/.github/workflows/template-ci.yml +++ b/.github/workflows/template-ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main] workflow_dispatch: + branch_protection_rule: + schedule: + - cron: "31 6 * * 2" permissions: contents: read @@ -25,6 +28,58 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 + governance: + name: Governance gates + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + - name: Fetch pull request base + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + run: git fetch --no-tags origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" + - name: Validate docs layout + run: python tools/check_docs_layout.py + - name: Validate attribution residue + run: python tools/check_ai_residue.py + env: + AI_RESIDUE_BASE_REF: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || github.event.before }} + - name: Validate ADR schema + run: python tools/check_adr_schema.py + env: + ADR_SCHEMA_BASE_REF: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || '' }} + - name: Validate pin parity + run: python tools/check_pin_parity.py + + repo-hygiene: + name: Org repo hygiene + permissions: + contents: read + uses: NWarila/.github/.github/workflows/reusable-repo-hygiene.yaml@5da716c68c90a86465d774f553d2f7161b60d616 # main + with: + source_ref: 5da716c68c90a86465d774f553d2f7161b60d616 + + codeql: + name: Org CodeQL + permissions: + contents: read + security-events: write + actions: read + uses: NWarila/.github/.github/workflows/reusable-codeql.yaml@5da716c68c90a86465d774f553d2f7161b60d616 # main + + scorecard: + name: Org OpenSSF Scorecard + if: github.event_name != 'pull_request' + permissions: + contents: read + security-events: write + id-token: write + actions: read + uses: NWarila/.github/.github/workflows/reusable-scorecard.yaml@5da716c68c90a86465d774f553d2f7161b60d616 # main + lint: runs-on: ubuntu-latest steps: @@ -122,13 +177,31 @@ jobs: ci-passed: if: always() - needs: [dependency-review, lint, types, tests, security, spelling, package, shellcheck, actionlint, markdownlint] + needs: + - dependency-review + - governance + - repo-hygiene + - codeql + - scorecard + - lint + - types + - tests + - security + - spelling + - package + - shellcheck + - actionlint + - markdownlint runs-on: ubuntu-latest steps: - name: Verify all checks passed shell: bash run: | echo "Dependency review: ${{ needs.dependency-review.result }}" + echo "Governance: ${{ needs.governance.result }}" + echo "Repo hygiene: ${{ needs.repo-hygiene.result }}" + echo "CodeQL: ${{ needs.codeql.result }}" + echo "Scorecard: ${{ needs.scorecard.result }}" echo "Lint: ${{ needs.lint.result }}" echo "Types: ${{ needs.types.result }}" echo "Tests: ${{ needs.tests.result }}" @@ -140,6 +213,10 @@ jobs: echo "Markdownlint: ${{ needs.markdownlint.result }}" if [[ "${{ needs.dependency-review.result }}" != "success" && "${{ needs.dependency-review.result }}" != "skipped" ]] || \ + [[ "${{ needs.governance.result }}" != "success" ]] || \ + [[ "${{ needs.repo-hygiene.result }}" != "success" ]] || \ + [[ "${{ needs.codeql.result }}" != "success" ]] || \ + [[ "${{ needs.scorecard.result }}" != "success" && "${{ needs.scorecard.result }}" != "skipped" ]] || \ [[ "${{ needs.lint.result }}" != "success" ]] || \ [[ "${{ needs.types.result }}" != "success" ]] || \ [[ "${{ needs.tests.result }}" != "success" ]] || \ diff --git a/sync-manifest.json b/sync-manifest.json index 18dc393..e83431c 100644 --- a/sync-manifest.json +++ b/sync-manifest.json @@ -10,6 +10,9 @@ { "src": "scripts/sync.py", "dest": ".github/scripts/sync.py", "mode": "overwrite" }, { "src": "scripts/setup.sh", "dest": ".github/scripts/setup.sh", "mode": "overwrite" }, { "src": "scripts/setup.ps1", "dest": ".github/scripts/setup.ps1", "mode": "overwrite" }, + { "src": "tools/check_docs_layout.py", "dest": "tools/check_docs_layout.py", "mode": "overwrite" }, + { "src": "tools/check_ai_residue.py", "dest": "tools/check_ai_residue.py", "mode": "overwrite" }, + { "src": "tools/check_adr_schema.py", "dest": "tools/check_adr_schema.py", "mode": "overwrite" }, { "src": "reference/pre-commit-config.yaml", "dest": ".pre-commit-config.yaml", "mode": "overwrite" }, { "src": "reference/markdownlint-cli2.jsonc", "dest": ".markdownlint-cli2.jsonc", "mode": "overwrite" }, { "src": "reference/settings.json", "dest": ".vscode/settings.json", "mode": "overwrite" }, diff --git a/tools/check_adr_schema.py b/tools/check_adr_schema.py new file mode 100644 index 0000000..9ade3ac --- /dev/null +++ b/tools/check_adr_schema.py @@ -0,0 +1,295 @@ +"""Validate ADR structure and living-update guardrails.""" + +from __future__ import annotations + +import datetime as dt +import os +import re +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +ADR_ROOT = ROOT / "docs" / "decision-records" +TODAY = dt.date.today() + +ADR_PATTERNS = ( + "docs/decision-records/[0-9][0-9][0-9][0-9]-*.md", + "docs/decision-records/org/[0-9][0-9][0-9][0-9]-*.md", + "docs/decision-records/template/[0-9][0-9][0-9][0-9]-*.md", + "docs/decision-records/repo/[0-9][0-9][0-9][0-9]-*.md", +) + +LEGACY_HEADINGS = ( + "## TL;DR", + "## Context and Problem Statement", + "## Decision Drivers", + "## Considered Options", + "## Decision Outcome", + "## Pros and Cons of the Options", + "## Confirmation", + "## Consequences", + "## Assumptions", + "## Supersedes", + "## Superseded by", + "## Implementing PRs", + "## Related ADRs", + "## Compliance Notes", +) + +LIVING_FIELDS = ( + "id", + "scope", + "status", + "decision-subject", + "date accepted", + "date", + "last reviewed", + "authors", + "decision-makers", + "consulted", + "informed", + "reversibility", + "review-by", +) + +VALID_STATUSES = {"proposed", "accepted", "superseded", "obsolete", "deprecated"} +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +ADR_ID_RE = re.compile(r"ADR-(\d{4})") + + +def run_git(args: list[str]) -> str | None: + try: + return subprocess.check_output( + ["git", *args], + cwd=ROOT, + encoding="utf-8", + stderr=subprocess.DEVNULL, + ) + except subprocess.CalledProcessError: + return None + + +def fail(errors: list[str], path: Path, message: str) -> None: + errors.append(f"{path.relative_to(ROOT).as_posix()}: {message}") + + +def adr_files() -> list[Path]: + files: list[Path] = [] + for pattern in ADR_PATTERNS: + files.extend(ROOT.glob(pattern)) + return sorted(files) + + +def parse_metadata(text: str) -> dict[str, str]: + lines = text.splitlines() + table: dict[str, str] = {} + in_table = False + for line in lines: + if not line.startswith("|"): + if in_table: + break + continue + in_table = True + cells = [cell.strip() for cell in line.strip().strip("|").split("|")] + if len(cells) != 2 or set(cells[0]) <= {"-", " "}: + continue + if cells[0].lower() == "field": + continue + table[cells[0].lower()] = cells[1] + return table + + +def section_body(text: str, heading: str) -> str: + marker = f"\n{heading}\n" + if marker not in f"\n{text}": + return "" + after = f"\n{text}".split(marker, 1)[1] + next_heading = re.search(r"\n## ", after) + if next_heading: + return after[: next_heading.start()].strip() + return after.strip() + + +def body_without_changelog(text: str) -> str: + marker = "\n## Changelog\n" + if marker not in f"\n{text}": + return text.strip() + return f"\n{text}".split(marker, 1)[0].strip() + + +def changelog_rows(text: str) -> list[str]: + body = section_body(text, "## Changelog") + rows: list[str] = [] + for line in body.splitlines(): + stripped = line.strip() + if not stripped.startswith("|"): + continue + cells = [cell.strip() for cell in stripped.strip("|").split("|")] + if not cells: + continue + first = cells[0].lower() + if first == "date" or set(cells[0]) <= {"-", " "}: + continue + rows.append(stripped) + return rows + + +def changed_adr_paths() -> set[str]: + paths: set[str] = set() + base_ref = os.environ.get("ADR_SCHEMA_BASE_REF", "").strip() + diff_args = [] + if base_ref and run_git(["rev-parse", "--verify", base_ref]) is not None: + diff_args.append( + [ + "diff", + "--name-only", + "--diff-filter=AM", + f"{base_ref}...HEAD", + "--", + "docs/decision-records", + ] + ) + diff_args.extend( + [ + ["diff", "--name-only", "--diff-filter=AM", "--", "docs/decision-records"], + ["diff", "--cached", "--name-only", "--diff-filter=AM", "--", "docs/decision-records"], + ] + ) + for args in diff_args: + diff = run_git(args) + if not diff: + continue + paths.update( + line.strip().replace("\\", "/") + for line in diff.splitlines() + if line.strip().endswith(".md") and not line.strip().endswith("/README.md") + ) + return paths + + +def old_text(base_ref: str, rel_path: str) -> str | None: + return run_git(["show", f"{base_ref}:{rel_path}"]) + + +def validate_required_shape(path: Path, text: str, errors: list[str]) -> None: + for heading in LEGACY_HEADINGS: + if f"\n{heading}\n" not in f"\n{text}\n": + fail(errors, path, f"missing required heading {heading!r}") + + +def validate_living_shape(path: Path, text: str, errors: list[str]) -> None: + metadata = parse_metadata(text) + for field in LIVING_FIELDS: + if field not in metadata or not metadata[field].strip(): + fail(errors, path, f"missing living metadata field {field!r}") + + status = metadata.get("status", "").lower().split()[0] + if status and status not in VALID_STATUSES: + fail(errors, path, f"unsupported status {metadata['status']!r}") + + for field in ("date accepted", "date", "last reviewed"): + value = metadata.get(field, "") + if value and not DATE_RE.match(value): + fail(errors, path, f"{field!r} must use YYYY-MM-DD") + + if "decision-maker" in metadata: + fail(errors, path, "use 'Decision-makers', not 'Decision-maker'") + + if "\n## Changelog\n" not in f"\n{text}\n": + fail(errors, path, "missing required heading '## Changelog'") + rows = changelog_rows(text) + if not rows: + fail(errors, path, "Changelog must contain at least one data row") + for row in rows: + cells = [cell.strip() for cell in row.strip("|").split("|")] + if len(cells) != 5 or any(not cell for cell in cells): + fail(errors, path, f"Changelog row must have five non-empty cells: {row!r}") + + last_reviewed = metadata.get("last reviewed", "") + if DATE_RE.match(last_reviewed): + reviewed = dt.date.fromisoformat(last_reviewed) + cadence_days = 365 if "/repo/" in path.as_posix() else 180 + if TODAY - reviewed > dt.timedelta(days=cadence_days): + fail(errors, path, f"Last reviewed is older than {cadence_days} days") + + +def validate_terminal_links(path: Path, text: str, files_by_number: dict[str, Path], errors: list[str]) -> None: + metadata = parse_metadata(text) + status = metadata.get("status", "").lower() + if not status.startswith("superseded"): + return + + successor_body = section_body(text, "## Superseded by") + match = ADR_ID_RE.search(successor_body) + if not match: + fail(errors, path, "Superseded ADR must name a successor in '## Superseded by'") + return + + successor = files_by_number.get(match.group(1)) + if successor is None: + fail(errors, path, f"successor ADR-{match.group(1)} does not exist") + return + + successor_text = successor.read_text(encoding="utf-8") + current_id = f"ADR-{path.name[:4]}" + if current_id not in section_body(successor_text, "## Supersedes"): + fail(errors, path, f"successor {successor.name} does not reciprocate Supersedes link") + + +def validate_changed_adr(path: Path, rel_path: str, text: str, errors: list[str]) -> None: + base_ref = os.environ.get("ADR_SCHEMA_BASE_REF", "").strip() + previous = old_text(base_ref, rel_path) if base_ref else None + + validate_living_shape(path, text, errors) + + new_rows = changelog_rows(text) + old_rows = changelog_rows(previous) if previous is not None else [] + if old_rows and new_rows[: len(old_rows)] != old_rows: + fail(errors, path, "Changelog rows must be append-only") + appended_rows = new_rows[len(old_rows) :] + + if previous is None: + if not appended_rows: + fail(errors, path, "new ADR must include an initial Changelog row") + return + + old_body = body_without_changelog(previous) + new_body = body_without_changelog(text) + old_metadata = parse_metadata(previous) + new_metadata = parse_metadata(text) + body_changed = old_body != new_body + review_changed = old_metadata.get("last reviewed") != new_metadata.get("last reviewed") + + if (body_changed or review_changed) and not appended_rows: + fail(errors, path, "ADR body or Last reviewed changed without a new Changelog row") + + old_status = old_metadata.get("status", "").lower().split()[0] + if old_status in {"superseded", "obsolete"} and body_changed: + fail(errors, path, f"terminal {old_status!r} ADR body must stay frozen") + + +def main() -> None: + errors: list[str] = [] + files = adr_files() + files_by_number = {path.name[:4]: path for path in files} + changed = changed_adr_paths() + + for path in files: + text = path.read_text(encoding="utf-8") + rel_path = path.relative_to(ROOT).as_posix() + validate_required_shape(path, text, errors) + validate_terminal_links(path, text, files_by_number, errors) + if rel_path in changed: + validate_changed_adr(path, rel_path, text, errors) + + if errors: + joined = "\n - ".join(errors) + raise SystemExit(f"ADR schema check failed:\n - {joined}") + + scope = f"{len(changed)} changed ADR(s)" if changed else "static ADR shape" + print(f"ADR schema check passed: {len(files)} ADR(s), {scope}") + + +if __name__ == "__main__": + main() diff --git a/tools/check_ai_residue.py b/tools/check_ai_residue.py new file mode 100644 index 0000000..fe4b0ad --- /dev/null +++ b/tools/check_ai_residue.py @@ -0,0 +1,96 @@ +"""Scan tracked text and PR commit messages for attribution residue.""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +MARKERS = tuple( + "".join(parts) + for parts in ( + ("Co", "dex"), + ("Clau", "de"), + ("Co", "pilot"), + ("Chat", "GPT"), + ("Ge", "mini"), + ("an", "thropic"), + ("Generated", " with"), + ("Co-authored", "-by"), + ) +) + + +def run_git(args: list[str]) -> str | None: + try: + return subprocess.check_output( + ["git", *args], + cwd=ROOT, + encoding="utf-8", + stderr=subprocess.DEVNULL, + ) + except subprocess.CalledProcessError: + return None + + +def tracked_paths() -> list[str]: + output = run_git(["ls-files", "--cached", "--others", "--exclude-standard"]) + if not output: + return [] + return [line.strip() for line in output.splitlines() if line.strip()] + + +def read_text(path: Path) -> str | None: + if not path.is_file(): + return None + data = path.read_bytes() + if b"\0" in data[:4096]: + return None + return data.decode("utf-8", errors="ignore") + + +def find_markers(label: str, text: str) -> list[str]: + haystack = text.casefold() + return [marker for marker in MARKERS if marker.casefold() in haystack] + + +def commit_messages() -> str: + base_ref = os.environ.get("AI_RESIDUE_BASE_REF", "").strip() + if base_ref and run_git(["rev-parse", "--verify", base_ref]) is not None: + return run_git(["log", "--format=%B", f"{base_ref}..HEAD"]) or "" + if os.environ.get("GITHUB_ACTIONS"): + return run_git(["log", "--format=%B", "-n", "1", "HEAD"]) or "" + return "" + + +def main() -> None: + failures: list[str] = [] + + for rel_path in tracked_paths(): + path = ROOT / rel_path + text = read_text(path) + if text is None: + continue + matches = find_markers(rel_path, text) + if matches: + failures.append(f"{rel_path}: {', '.join(matches)}") + + messages = commit_messages() + if messages: + matches = find_markers("commit messages", messages) + if matches: + failures.append(f"commit messages: {', '.join(matches)}") + + if failures: + joined = "\n - ".join(failures) + raise SystemExit(f"attribution-residue check failed:\n - {joined}") + + print("attribution-residue check passed") + + +if __name__ == "__main__": + main() diff --git a/tools/check_docs_layout.py b/tools/check_docs_layout.py new file mode 100644 index 0000000..004f0ce --- /dev/null +++ b/tools/check_docs_layout.py @@ -0,0 +1,68 @@ +"""Validate the org documentation layout.""" + +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +DOCS = ROOT / "docs" + +ALLOWED_DOC_DIRS = { + "decision-records", + "diagrams", + "explanation", + "how-to", + "reference", + "runbooks", + "tutorials", +} + +ALLOWED_DIAGRAM_SUFFIXES = {".mmd"} + + +def fail(message: str) -> None: + raise SystemExit(f"docs-layout check failed: {message}") + + +def rel(path: Path) -> str: + return path.relative_to(ROOT).as_posix() + + +def main() -> None: + if not DOCS.is_dir(): + fail("docs/ is missing") + + missing = [name for name in sorted(ALLOWED_DOC_DIRS) if not (DOCS / name).is_dir()] + if missing: + fail(f"required docs subdirectories are missing: {missing}") + + unexpected_dirs = [ + rel(path) + for path in DOCS.iterdir() + if path.is_dir() and path.name not in ALLOWED_DOC_DIRS + ] + if unexpected_dirs: + fail(f"unexpected top-level docs directories: {unexpected_dirs}") + + misplaced_markdown = [ + rel(path) + for path in DOCS.rglob("*.md") + if path.relative_to(DOCS).parts[0] not in (ALLOWED_DOC_DIRS - {"diagrams"}) + ] + if misplaced_markdown: + fail(f"Markdown files are outside sanctioned docs subtrees: {misplaced_markdown}") + + bad_diagrams = [ + rel(path) + for path in (DOCS / "diagrams").rglob("*") + if path.is_file() and path.name != ".gitkeep" and path.suffix not in ALLOWED_DIAGRAM_SUFFIXES + ] + if bad_diagrams: + fail(f"diagram sources must use .mmd: {bad_diagrams}") + + print("docs-layout check passed") + + +if __name__ == "__main__": + main() diff --git a/tools/check_pin_parity.py b/tools/check_pin_parity.py new file mode 100644 index 0000000..de71138 --- /dev/null +++ b/tools/check_pin_parity.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Validate reference pre-commit hook revs against reference pyproject pins.""" + +from __future__ import annotations + +import re +import sys +import tomllib +from pathlib import Path +from typing import Any, cast + +ROOT = Path(__file__).resolve().parents[1] +PYPROJECT = ROOT / "reference" / "pyproject.toml" +PRE_COMMIT = ROOT / "reference" / "pre-commit-config.yaml" + +HOOK_PACKAGE_BY_REPO = { + "https://github.com/astral-sh/ruff-pre-commit": "ruff", + "https://github.com/pre-commit/mirrors-mypy": "mypy", + "https://github.com/codespell-project/codespell": "codespell", + "https://github.com/abravalheri/validate-pyproject": "validate-pyproject", +} + +DEPENDENCY_PIN_RE = re.compile(r"^([A-Za-z0-9_.-]+)==([^\s;]+)") +REPO_RE = re.compile(r"^\s*-\s+repo:\s+(.+?)\s*$") +REV_RE = re.compile(r"^\s+rev:\s+(.+?)\s*$") + + +def normalize_name(name: str) -> str: + return name.replace("_", "-").lower() + + +def normalize_value(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + value = value[1:-1] + return value.strip() + + +def normalize_rev(rev: str) -> str: + value = normalize_value(rev) + if value.startswith("v"): + return value[1:] + return value + + +def load_pyproject_pins() -> dict[str, str]: + data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8")) + project = cast(dict[str, Any], data.get("project", {})) + optional_dependencies = cast(dict[str, Any], project.get("optional-dependencies", {})) + dev_dependencies = cast(list[str], optional_dependencies.get("dev", [])) + + pins: dict[str, str] = {} + for dependency in dev_dependencies: + match = DEPENDENCY_PIN_RE.match(dependency) + if match is None: + continue + pins[normalize_name(match.group(1))] = match.group(2) + return pins + + +def load_pre_commit_revs() -> dict[str, str]: + revs: dict[str, str] = {} + current_repo = "" + + for line in PRE_COMMIT.read_text(encoding="utf-8").splitlines(): + repo_match = REPO_RE.match(line) + if repo_match is not None: + current_repo = normalize_value(repo_match.group(1)) + continue + + rev_match = REV_RE.match(line) + if rev_match is None or current_repo not in HOOK_PACKAGE_BY_REPO: + continue + + package = HOOK_PACKAGE_BY_REPO[current_repo] + revs[package] = normalize_rev(rev_match.group(1)) + current_repo = "" + + return revs + + +def parity_errors(pyproject_pins: dict[str, str], pre_commit_revs: dict[str, str]) -> list[str]: + errors: list[str] = [] + + for package in sorted(HOOK_PACKAGE_BY_REPO.values()): + pyproject_pin = pyproject_pins.get(package) + pre_commit_rev = pre_commit_revs.get(package) + + if pyproject_pin is None: + errors.append(f"reference/pyproject.toml dev dependency is missing an exact {package!r} pin") + continue + + if pre_commit_rev is None: + errors.append(f"reference/pre-commit-config.yaml is missing a tracked {package!r} hook rev") + continue + + if pyproject_pin != pre_commit_rev: + errors.append( + f"{package}: reference/pyproject.toml pins {pyproject_pin}, " + f"but reference/pre-commit-config.yaml uses {pre_commit_rev}" + ) + + return errors + + +def main() -> int: + errors = parity_errors(load_pyproject_pins(), load_pre_commit_revs()) + if errors: + sys.stderr.write("pin-parity check failed:\n") + for error in errors: + sys.stderr.write(f" - {error}\n") + return 1 + + sys.stdout.write("pin-parity check passed\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main())