diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..5083a10 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +./scripts/check-public-safety.sh +python3 ./scripts/check-markdown-links.py + +create_backup_ref() { + local backup_ref + backup_ref="backup/pre-push-main-$(date +%Y%m%d-%H%M%S)" + git branch "$backup_ref" HEAD >/dev/null 2>&1 || true + echo "Created local backup branch: $backup_ref" +} + +while IFS=' ' read -r local_ref local_sha remote_ref remote_sha; do + if [ "$remote_ref" = "refs/heads/main" ]; then + create_backup_ref + + if [ "${ALLOW_MAIN_PUSH:-0}" != "1" ]; then + echo + echo "Push to main blocked by local repo guard." + echo "Use a branch + PR flow by default." + echo "If you truly need an emergency direct push, rerun with:" + echo "ALLOW_MAIN_PUSH=1 git push origin HEAD:main" + exit 1 + fi + fi +done + +exit 0 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..29212c2 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,18 @@ +## Summary + +- What changed? +- Why now? + +## Checks + +- [ ] I ran `./scripts/check-public-safety.sh` +- [ ] I ran `python3 ./scripts/check-markdown-links.py` +- [ ] I reviewed [publication-safety-checklist.md](../docs/publication-safety-checklist.md) +- [ ] The change is public-safe and does not include private runtime wiring, credentials, or operational IDs +- [ ] Examples and screenshots are synthetic or safe for public distribution + +## Reviewer Focus + +- Privacy or publication risk: +- Claim quality or overstatement risk: +- Link or formatting risk: diff --git a/.github/workflows/repo-qa.yml b/.github/workflows/repo-qa.yml new file mode 100644 index 0000000..e66825a --- /dev/null +++ b/.github/workflows/repo-qa.yml @@ -0,0 +1,35 @@ +name: Repo QA + +on: + pull_request: + push: + branches: + - main + - "codex/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + public-safe-qa: + name: Public-Safe QA + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install ripgrep + run: sudo apt-get update && sudo apt-get install -y ripgrep + + - name: Run public-safety scan + run: ./scripts/check-public-safety.sh + + - name: Validate local Markdown links + run: python3 ./scripts/check-markdown-links.py diff --git a/docs/repo-publish-workflow.md b/docs/repo-publish-workflow.md new file mode 100644 index 0000000..5d827c5 --- /dev/null +++ b/docs/repo-publish-workflow.md @@ -0,0 +1,32 @@ +# Repo Publish Workflow + +This repo is public, so publication safety has to be enforced by default rather than remembered ad hoc. + +## Intended Path + +1. Make changes on a branch, not directly on `main`. +2. Open a pull request. +3. Let `Repo QA` run the public-safety scan and Markdown QA checks. +4. Review the PR against [`publication-safety-checklist.md`](./publication-safety-checklist.md). +5. Merge only after the checks pass. + +## Local Guard On This Machine + +This clone also uses a local `pre-push` hook in [`.githooks/pre-push`](../.githooks/pre-push) to make the safe path the default even before GitHub settings catch up. + +- every push runs the public-safety scan and Markdown link checks +- any push to `main` creates a local backup branch first +- direct pushes to `main` are blocked unless you explicitly override with `ALLOW_MAIN_PUSH=1` + +## What The Automated Gate Catches + +- credential patterns and private key material +- Telegram bot tokens and operational IDs +- Slack workspace links and likely private dashboard links +- missing local Markdown targets + +## What Still Needs GitHub Settings + +The workflow is now in the repo, but GitHub branch protection should require the `Public-Safe QA` check before `main` can move. + +That last step lives in GitHub settings, not in this public repository. diff --git a/scripts/check-markdown-links.py b/scripts/check-markdown-links.py new file mode 100755 index 0000000..d967d9f --- /dev/null +++ b/scripts/check-markdown-links.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path +from urllib.parse import unquote + + +ROOT = Path(__file__).resolve().parents[1] +SKIP_PREFIXES = ("http://", "https://", "mailto:", "tel:") +TITLE_SUFFIX_RE = re.compile( + r"""^(?P.*?)(?:\s+(?:"[^"]*"|'[^']*'|\([^()]*\)))?\s*$""" +) + + +def tracked_markdown_files() -> list[Path]: + result = subprocess.run( + ["git", "ls-files", "*.md"], + cwd=ROOT, + capture_output=True, + text=True, + check=True, + ) + return [ROOT / line for line in result.stdout.splitlines() if line.strip()] + + +def normalize_target(raw_target: str) -> str: + target = raw_target.strip() + if not target: + return "" + + if target.startswith("<"): + closing = target.find(">") + target = target[1:closing] if closing != -1 else target[1:] + else: + title_match = TITLE_SUFFIX_RE.match(target) + if title_match: + target = title_match.group("target").strip() + + return unquote(target) + + +def should_skip(target: str) -> bool: + if not target or target.startswith("#"): + return True + return target.startswith(SKIP_PREFIXES) + + +def resolve_target(source: Path, target: str) -> Path: + path_only = target.split("#", 1)[0] + return (source.parent / path_only).resolve() + + +def find_closing_bracket(markdown: str, start: int) -> int: + depth = 1 + index = start + 1 + + while index < len(markdown): + char = markdown[index] + if char == "\\": + index += 2 + continue + if char == "[": + depth += 1 + elif char == "]": + depth -= 1 + if depth == 0: + return index + index += 1 + + return -1 + + +def extract_parenthesized(markdown: str, start: int) -> tuple[str, int] | None: + depth = 0 + in_angle = False + chars: list[str] = [] + index = start + 1 + + while index < len(markdown): + char = markdown[index] + if char == "\\" and index + 1 < len(markdown): + chars.append(char) + chars.append(markdown[index + 1]) + index += 2 + continue + + if char == "<" and not in_angle: + in_angle = True + elif char == ">" and in_angle: + in_angle = False + elif not in_angle and char == "(": + depth += 1 + elif not in_angle and char == ")": + if depth == 0: + return ("".join(chars), index) + depth -= 1 + + chars.append(char) + index += 1 + + return None + + +def collect_targets(markdown: str) -> list[str]: + targets: list[str] = [] + index = 0 + + while index < len(markdown): + char = markdown[index] + if char == "!" and index + 1 < len(markdown) and markdown[index + 1] == "[": + label_start = index + 1 + elif char == "[": + label_start = index + else: + index += 1 + continue + + label_end = find_closing_bracket(markdown, label_start) + if label_end == -1 or label_end + 1 >= len(markdown) or markdown[label_end + 1] != "(": + index = label_start + 1 + continue + + extracted = extract_parenthesized(markdown, label_end + 1) + if extracted is None: + index = label_end + 1 + continue + + target, target_end = extracted + targets.append(target) + index = target_end + 1 + + return targets + + +def main() -> int: + failures: list[str] = [] + + for markdown_file in tracked_markdown_files(): + content = markdown_file.read_text(encoding="utf-8") + for raw_target in collect_targets(content): + target = normalize_target(raw_target) + if should_skip(target): + continue + + resolved = resolve_target(markdown_file, target) + if not resolved.exists(): + failures.append( + f"{markdown_file.relative_to(ROOT)} -> {target} (missing: {resolved.relative_to(ROOT) if resolved.is_relative_to(ROOT) else resolved})" + ) + + if failures: + print("Markdown link checks failed:", file=sys.stderr) + for failure in failures: + print(f"- {failure}", file=sys.stderr) + return 1 + + print("Markdown link checks passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-public-safety.sh b/scripts/check-public-safety.sh new file mode 100755 index 0000000..28c0cfb --- /dev/null +++ b/scripts/check-public-safety.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if ! command -v rg >/dev/null 2>&1; then + echo "ripgrep (rg) is required for public-safety checks." >&2 + exit 1 +fi + +declare -a files=() +while IFS= read -r -d '' file; do + files+=("$file") +done < <(git ls-files -z) + +if [ ${#files[@]} -eq 0 ]; then + echo "No tracked files to scan." + exit 0 +fi + +checks=( + "GitHub PAT|ghp_[A-Za-z0-9]{36}" + "GitHub fine-grained PAT|github_pat_[A-Za-z0-9_]{20,}" + "OpenAI key|sk-[A-Za-z0-9]{20,}" + "Slack token|xox[baprs]-[A-Za-z0-9-]{10,}" + "AWS access key|AKIA[0-9A-Z]{16}" + "Private key block|-----BEGIN (OPENSSH|RSA|DSA|EC|PGP) PRIVATE KEY-----" + "Credential assignment|(?i)(api[_-]?key|secret|token|password)\\s*[:=]\\s*[\"'][^\"'\\n]{8,}[\"']" + "Telegram bot token|[0-9]{8,10}:[A-Za-z0-9_-]{35}" + "Operational ID wiring|(?i)(chat[_ -]?id|thread[_ -]?id|sender[_ -]?id)\\s*[:=]\\s*[\"']?-?[0-9]{6,}[\"']?" + "Slack workspace link|https://[^ )]*slack\\.com/" + "Private dashboard link|https://[^ )]*(looker|mode\\.com|metabase|superset)[^ )]*" +) + +failures=0 + +for rule in "${checks[@]}"; do + label=${rule%%|*} + regex=${rule#*|} + + if rg --pcre2 -n -I --color=never "$regex" "${files[@]}" >/tmp/ai_plus_data_public_safety_match.txt 2>/dev/null; then + failures=1 + echo + echo "[FAIL] ${label}" + cat /tmp/ai_plus_data_public_safety_match.txt + fi +done + +rm -f /tmp/ai_plus_data_public_safety_match.txt + +if [ "$failures" -ne 0 ]; then + echo + echo "Public-safety checks failed. Remove private runtime details, credentials, or operational identifiers before publishing." + exit 1 +fi + +echo "Public-safety checks passed."