diff --git a/.gitea/krystie-release.pub.asc b/.gitea/krystie-release.pub.asc new file mode 100644 index 0000000..679642b --- /dev/null +++ b/.gitea/krystie-release.pub.asc @@ -0,0 +1,65 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=zWaw +-----END PGP PUBLIC KEY BLOCK----- diff --git a/.gitea/krystie_gate.py b/.gitea/krystie_gate.py new file mode 100644 index 0000000..cd059ff --- /dev/null +++ b/.gitea/krystie_gate.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Krystie Gate — static check stage of the CI gate. + +Runs inside the Gitea Actions runner. Inspects all commits that were just +pushed to a krystie-wip/* branch and rejects if any violates the gate rules. + +Decision per commit: + * If signed by Krystie's GPG key (fingerprint DCF2579968107984), apply the + full per-repo gate. + * If signed by a different key OR unsigned, allow (Sami's authority). + +Per-repo enforcement: + * triangles_v5 : red-list (consensus paths) + test-first + no-clearnet + * triangles-explorer, triangles-api, tridock-web-wallet, sami-chat, tri-pi: + test-first only + * homebrew-triangles: formula syntax check only + +Outputs: + * On reject, prints REJECTED lines to stderr and exits 1. + * On accept, sets `is_krystie_commit` GH-actions output to true/false. +""" +from __future__ import annotations + +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +# Krystie's GPG identity. We accept both the primary long-ID and the +# signing subkey because `git log %GK` returns the subkey that was actually +# used to sign, not the primary. The full primary fingerprint is also +# included so a paranoid future check can validate the chain. +KRYSTIE_PRIMARY_FP = "523A81833EB7201573E1EFE1DCF2579968107984" +KRYSTIE_KEY_IDS = { + "DCF2579968107984", # primary long-ID + "C2DC60618C85A159", # signing subkey long-ID +} + +RED_LIST_TRIANGLES_V5 = [ + re.compile(r"^src/main\.(cpp|h)$"), + re.compile(r"^src/validation.*"), + re.compile(r"^src/kernel\.(cpp|h)$"), + re.compile(r"^src/checkpoints\.(cpp|h)$"), + re.compile(r"^src/consensus/"), + re.compile(r"^src/protocol\.(cpp|h)$"), + re.compile(r"^src/net\.(cpp|h)$"), + re.compile(r"^src/netbase\.(cpp|h)$"), + re.compile(r"^src/net_bootstrap\.(cpp|h)$"), + re.compile(r"^src/chainparams.*"), + re.compile(r"^src/clientversion\.h$"), + re.compile(r"^src/key\.(cpp|h)$"), + re.compile(r"^src/keystore\.(cpp|h)$"), + re.compile(r"^src/onionseed\.h$"), + re.compile(r"^contrib/seeds/"), + re.compile(r"^contrib/devtools/release.*"), + re.compile(r"^doc/release-process\.txt$"), +] + +TEST_DIRS = { + "triangles_v5": ["src/test/", "test/"], + "triangles-explorer": ["src/__tests__/", "tests/", "test/"], + "triangles-api": ["test/", "__tests__/", "tests/"], + "tridock-web-wallet": ["test/", "__tests__/", "tests/"], + "sami-chat": ["test/", "__tests__/", "tests/"], + "tri-pi": ["test/", "tests/"], + "homebrew-triangles": [], +} + +SOURCE_EXTS = { + "triangles_v5": {".cpp", ".h", ".c"}, + "triangles-explorer": {".ts", ".tsx", ".js", ".svelte"}, + "triangles-api": {".js", ".ts"}, + "tridock-web-wallet": {".ts", ".tsx", ".js", ".svelte", ".vue"}, + "sami-chat": {".ts", ".tsx", ".js", ".svelte", ".vue"}, + "tri-pi": {".py", ".sh", ".ts", ".js"}, + "homebrew-triangles": set(), +} + +RED_LIST_REPOS = {"triangles_v5"} + +PEER_CONFIG_PATHS = [ + re.compile(r"^contrib/seeds/"), + re.compile(r"^src/chainparams.*"), + re.compile(r".*triangles\.conf(\.example)?$"), +] + + +@dataclass +class GateResult: + ok: bool + reason: str = "" + + +def repo_name() -> str: + repo = os.environ.get("GITHUB_REPOSITORY", "") + return repo.split("/", 1)[1] if "/" in repo else repo + + +def commit_signer(sha: str) -> str | None: + try: + out = subprocess.run( + ["git", "log", "-1", "--format=%GK", sha], + check=True, capture_output=True, text=True, + ).stdout.strip() + return out or None + except subprocess.CalledProcessError: + return None + + +def is_krystie_commit(sha: str) -> bool: + fp = commit_signer(sha) + if not fp: + return False + # Accept any key ID we know belongs to Krystie. `git log %GK` returns the + # signing subkey, so we have to whitelist both primary and subkey. + return any(fp == known or known.endswith(fp) for known in KRYSTIE_KEY_IDS) + + +def commits_in_push() -> list[str]: + before = os.environ.get("GITHUB_BEFORE", "") + sha = os.environ.get("GITHUB_SHA", "") + if not sha: + return [] + if not before or set(before) == {"0"}: + # New branch — only inspect the head commit (don't walk history) + return [sha] + # On force-push, `before` may have been orphaned and is unreachable in the + # checked-out repo. `git rev-list before..sha` then exits 128. Fall back + # to inspecting the new head only — that's the safest guarantee we can + # make about what just landed. + try: + out = subprocess.run( + ["git", "rev-list", f"{before}..{sha}"], + check=True, capture_output=True, text=True, + ).stdout + return [c for c in out.split() if c] + except subprocess.CalledProcessError: + return [sha] + + +def changed_files(sha: str) -> list[str]: + out = subprocess.run( + ["git", "diff-tree", "--no-commit-id", "--name-only", "-r", sha], + check=True, capture_output=True, text=True, + ).stdout + return [f for f in out.split("\n") if f] + + +def commit_diff_text(sha: str, paths: list[str]) -> str: + if not paths: + return "" + out = subprocess.run( + ["git", "show", "--no-color", sha, "--"] + paths, + check=True, capture_output=True, text=True, + ).stdout + return out + + +def red_list_check(repo: str, files: list[str]) -> GateResult: + if repo not in RED_LIST_REPOS: + return GateResult(True) + for f in files: + for pat in RED_LIST_TRIANGLES_V5: + if pat.match(f): + return GateResult(False, f"red-list violation: '{f}' is consensus/critical-path; needs Sami review (open red-list-labeled issue)") + return GateResult(True) + + +def _is_test_path(f: str, test_dirs: list[str]) -> bool: + return any(f.startswith(d) for d in test_dirs) or "/test/" in f or "/tests/" in f or "/__tests__/" in f + + +def test_first_check(repo: str, files: list[str]) -> GateResult: + src_exts = SOURCE_EXTS.get(repo, set()) + test_dirs = TEST_DIRS.get(repo, []) + if not src_exts or not test_dirs: + return GateResult(True) + src_changed = any(any(f.endswith(e) for e in src_exts) and not _is_test_path(f, test_dirs) for f in files) + test_changed = any(_is_test_path(f, test_dirs) for f in files) + if src_changed and not test_changed: + return GateResult(False, f"test-first violation: source changed without paired test; expected test under {test_dirs}") + return GateResult(True) + + +def no_clearnet_check(repo: str, sha: str, files: list[str]) -> GateResult: + if repo != "triangles_v5": + return GateResult(True) + peer_files = [f for f in files if any(p.match(f) for p in PEER_CONFIG_PATHS)] + if not peer_files: + return GateResult(True) + diff = commit_diff_text(sha, peer_files) + for line in diff.split("\n"): + if not line.startswith("+") or line.startswith("+++"): + continue + body = line[1:].strip() + if re.search(r"\b(addnode|seednode|connect)\s*=", body, re.IGNORECASE): + if ".onion" not in body.lower(): + return GateResult(False, f"no-clearnet: added peer/seed without .onion: {body[:120]}") + if re.match(r"^\s*(\d{1,3}\.){3}\d{1,3}\b", body) or re.match(r"^\s*[0-9a-fA-F:]{4,}\b", body): + return GateResult(False, f"no-clearnet: clearnet address added: {body[:120]}") + return GateResult(True) + + +def gate_commit(repo: str, sha: str) -> list[str]: + files = changed_files(sha) + failures = [] + for check, args in [ + (red_list_check, (repo, files)), + (test_first_check, (repo, files)), + (no_clearnet_check, (repo, sha, files)), + ]: + r = check(*args) + if not r.ok: + failures.append(f"commit {sha[:12]}: {r.reason}") + return failures + + +def emit_output(name: str, value: str): + out_file = os.environ.get("GITHUB_OUTPUT", "") + if out_file: + with open(out_file, "a") as fh: + fh.write(f"{name}={value}\n") + + +def main() -> int: + repo = repo_name() + if not repo: + print("ERROR: GITHUB_REPOSITORY not set", file=sys.stderr) + return 2 + + commits = commits_in_push() + if not commits: + print("No commits to inspect", file=sys.stdout) + emit_output("is_krystie_commit", "false") + return 0 + + krystie_count = 0 + all_failures: list[str] = [] + for sha in commits: + if not is_krystie_commit(sha): + print(f" {sha[:12]}: not Krystie-signed (allow)") + continue + krystie_count += 1 + print(f" {sha[:12]}: Krystie-signed; running gate") + failures = gate_commit(repo, sha) + all_failures.extend(failures) + + emit_output("is_krystie_commit", "true" if krystie_count > 0 else "false") + + if all_failures: + print(f"\n[KRYSTIE GATE] REJECTED on {repo}:", file=sys.stderr) + for f in all_failures: + print(f" - {f}", file=sys.stderr) + return 1 + print(f"[KRYSTIE GATE] PASS on {repo} ({krystie_count} Krystie commit(s) inspected, {len(commits) - krystie_count} non-Krystie)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.gitea/workflows/krystie-gate.yml b/.gitea/workflows/krystie-gate.yml new file mode 100644 index 0000000..ff0d248 --- /dev/null +++ b/.gitea/workflows/krystie-gate.yml @@ -0,0 +1,132 @@ +name: Krystie Gate + +# Runs on every push to krystie-wip/* branches. +# Static checks first (cheap), then build + tests. +# If everything green AND the commit is Krystie's, fast-forwards master. +# Sami's pushes (admin) bypass this entire flow — he goes direct to master. + +on: + push: + branches: + - 'krystie-wip/**' + +jobs: + static-gate: + name: "Static gate (red-list / test-first / no-clearnet)" + runs-on: ubuntu-latest + outputs: + is_krystie_commit: ${{ steps.gate.outputs.is_krystie_commit }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Import Krystie public key (for verification) + run: | + mkdir -p ~/.gnupg && chmod 700 ~/.gnupg + if [ -f .gitea/krystie-release.pub.asc ]; then + gpg --import .gitea/krystie-release.pub.asc + # Mark the key as ultimately trusted so `git log %GK` will consider + # signatures valid. Without this, %GK returns empty and the gate + # treats Krystie's commits as unsigned, defeating the whole point. + FP=$(gpg --list-keys --with-colons | awk -F: '/^fpr:/ {print $10; exit}') + echo "${FP}:6:" | gpg --import-ownertrust + echo "Imported and trusted Krystie public key: ${FP}" + # Configure git to call gpg for verification (it does by default, + # but explicit doesn't hurt) and not to require signed-by-default. + git config --global gpg.program gpg + else + echo "WARN: .gitea/krystie-release.pub.asc not found — gate will treat all commits as non-Krystie (i.e. allow)" + fi + - name: Run gate + id: gate + env: + GITHUB_REF: ${{ github.ref }} + GITHUB_SHA: ${{ github.sha }} + GITHUB_BEFORE: ${{ github.event.before }} + run: | + python3 .gitea/krystie_gate.py + + build-and-test: + name: "Build + ctest" + needs: static-gate + runs-on: ubuntu-latest + if: ${{ needs.static-gate.result == 'success' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 + - name: Install build deps + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential cmake ninja-build pkg-config \ + libssl-dev libboost-all-dev libdb++-dev libleveldb-dev \ + librocksdb-dev libevent-dev libsodium-dev \ + libsecp256k1-dev || true + # Some packages may not be available; the C++20 / RocksDB modernization + # is in flight, so missing deps are tolerable for v1 of the gate. + - name: Configure (daemon-only, no Qt) + run: | + mkdir -p build && cd build + cmake .. -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_QT=OFF \ + -DBUILD_TESTS=ON \ + -DBUILD_ROCKSDB=OFF \ + || (echo "::warning::CMake configure failed — likely WIP modernization. Allowing build skip for v1." && exit 0) + - name: Build + run: | + if [ -f build/build.ninja ]; then + cd build && ninja -j$(nproc) 2>&1 | tail -100 || (echo "::warning::Build failed — flagging for Sami review" && exit 1) + else + echo "::warning::No build.ninja produced; skipping for v1" + fi + - name: ctest + run: | + if [ -f build/CTestTestfile.cmake ]; then + cd build && ctest --output-on-failure -j$(nproc) || exit 1 + else + echo "::warning::No ctest produced; skipping for v1 — Krystie should add tests in src/test/" + fi + + auto-merge: + name: "Auto-merge to master" + needs: [static-gate, build-and-test] + runs-on: ubuntu-latest + if: ${{ needs.static-gate.result == 'success' && needs.build-and-test.result == 'success' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.KRYSTIE_GITEA_TOKEN }} + - name: Fast-forward master to this branch + env: + GITEA_TOKEN: ${{ secrets.KRYSTIE_GITEA_TOKEN }} + BRANCH: ${{ github.ref_name }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + # The wip branch is master + N Krystie commits. A plain push with + # the wip sha onto refs/heads/master succeeds iff the update is a + # fast-forward — which is exactly the safety we want. (Earlier + # versions called PATCH /branches/master which is Gitea's branch- + # rename endpoint, not a ref-update endpoint, and always failed.) + REPO="${GITHUB_REPOSITORY}" # owner/name + GIT_URL="http://localhost:3030/${REPO}.git" + git -c "http.extraHeader=Authorization: token ${GITEA_TOKEN}" \ + push "${GIT_URL}" "${SHA}:refs/heads/master" \ + && echo "Master fast-forwarded to ${SHA:0:12}" \ + || (echo "::error::Fast-forward push refused — master has likely diverged" && exit 1) + # Clean up the wip branch via the same push channel (delete = empty source). + git -c "http.extraHeader=Authorization: token ${GITEA_TOKEN}" \ + push "${GIT_URL}" ":refs/heads/${BRANCH}" \ + && echo "Cleaned up wip branch ${BRANCH}" \ + || echo "::warning::Could not delete wip branch (it'll get pruned later)" diff --git a/docs/krystie-runner-log.md b/docs/krystie-runner-log.md new file mode 100644 index 0000000..1f312f1 --- /dev/null +++ b/docs/krystie-runner-log.md @@ -0,0 +1,7 @@ +# Krystie runner log + +This file records autonomous-runner activity. Each entry is a doc-only +edit produced by the demo worker; once OpenClaw is wired in this log +will be replaced by real work. + +- [2026-04-29T06:57:30Z] triangles_v5#1 — Smoke-test the Krystie loop runner