Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/constraints-ci.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
torch==2.6.0
pytest==9.1.1
ruff==0.16.4
89 changes: 89 additions & 0 deletions .github/scripts/check_doc_links.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Check *inline* relative Markdown links under the given roots.

Scope, stated precisely because a checker that overstates its coverage is worse
than one that admits its limits:

handled inline links -- [text](path), [text](<path>), [text](path "title"),
with an optional #fragment; fenced code blocks are skipped
NOT handled reference-style links ([text][ref] + [ref]: path), destinations
spanning multiple lines, destinations containing balanced
parentheses, single-quoted or parenthesised titles, and
percent-encoded paths

Anything in the "not handled" list is invisible to this check, not tolerated by
it. The Documentation/ corpus contains none of those forms today (verified: 0
fenced links, 0 reference definitions); widen this script before relying on it
for a tree that does.
"""
from __future__ import annotations

import re
import sys
from pathlib import Path

LINK = re.compile(r"\[[^\]]*\]\(\s*<?([^)>\s]+)>?(?:\s+\"[^\"]*\")?\s*\)")
# Fence opener/closer. Written with `{3,}` rather than literal backticks so this
# file can itself be embedded in a Markdown fence without terminating it.
FENCE = re.compile(r"^\s{0,3}(`{3,}|~{3,})\s*(.*)$")
EXTERNAL = ("http://", "https://", "mailto:", "tel:", "ftp://", "//")


def iter_prose(text: str):
"""Yield (lineno, line) for lines outside fenced code blocks.

Tracks the opening fence's character and length: a fence closes only on the
same character, at least as long, and with no trailing info string. Without
that, a ``~~~`` block containing a triple backtick toggles the state and the
rest of the file is misclassified.
"""
fence_char: str | None = None
fence_len = 0
for lineno, line in enumerate(text.splitlines(), 1):
match = FENCE.match(line)
if match:
run, info = match.group(1), match.group(2).strip()
if fence_char is None:
fence_char, fence_len = run[0], len(run)
continue
if run[0] == fence_char and len(run) >= fence_len and not info:
fence_char, fence_len = None, 0
continue
# a shorter/different run inside a fence is content, not a closer
continue
if fence_char is None:
yield lineno, line


def main(roots: list[str]) -> int:
repo = Path(__file__).resolve().parents[2]
broken, checked = [], 0
for root in roots:
for md in sorted((repo / root).rglob("*.md")):
text = md.read_text(encoding="utf-8", errors="replace")
for lineno, line in iter_prose(text):
for href in LINK.findall(line):
if href.startswith(EXTERNAL) or href.startswith("#"):
continue
target = href.split("#", 1)[0]
if not target:
continue
checked += 1
if (md.parent / target).exists():
continue
if (repo / target.lstrip("/")).exists():
continue
broken.append(f"{md.relative_to(repo)}:{lineno} -> {href}")

print(f"checked {checked} inline relative link(s) in: {', '.join(roots)}")
if broken:
print(f"\n{len(broken)} broken link(s):")
for b in broken:
print(f" {b}")
return 1
print("all inline relative links resolve")
return 0


if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:] or ["Documentation"]))
119 changes: 119 additions & 0 deletions .github/scripts/forge_lint_gate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Fail closed on `forge lint --json` output.

Reads JSON-lines diagnostics on stdin and exits non-zero if anything is wrong.
This is a required, security-sensitive gate, so it validates the *schema* it was
written against rather than only the JSON syntax:

* an unparseable line -> fail
* an unrecognised `$message_type` -> fail (forge's output changed)
* a diagnostic missing `level` or a code -> fail (schema drifted)
* a diagnostic whose `level` is unknown -> fail (new severity, unclassified)
* any diagnostic at `warning` or `error` -> fail (the actual findings)

Empty input is valid and means a clean tree. The caller must check forge's own
exit status separately; this script never sees it.

Pinned against Foundry v1.7.1, whose `forge lint --json` emits only
`$message_type: "diagnostic"` records. A new record type is treated as a
breaking change to be reviewed, not as something to skip silently.
"""
from __future__ import annotations

import json
import sys
from collections import Counter

# Severities forge can emit, partitioned into what fails and what does not.
FAIL_LEVELS = frozenset({"warning", "error"})
PASS_LEVELS = frozenset({"note", "help", "info"})
KNOWN_LEVELS = FAIL_LEVELS | PASS_LEVELS

# Record types this parser was written against.
KNOWN_MESSAGE_TYPES = frozenset({"diagnostic"})


class SchemaError(Exception):
"""forge's output does not match what this gate was written against."""


def _diagnostic_code(record: dict) -> str:
code = record.get("code")
if not isinstance(code, dict):
raise SchemaError("diagnostic has no `code` object")
value = code.get("code")
if not isinstance(value, str) or not value:
raise SchemaError("diagnostic `code.code` is missing or not a string")
return value


def _primary_location(record: dict) -> str:
for span in record.get("spans") or []:
if isinstance(span, dict) and span.get("is_primary"):
return f"{span.get('file_name')}:{span.get('line_start')}:{span.get('column_start')}"
return "<no primary span>"


def scan(stream) -> tuple[Counter, int]:
"""Return (failing counts by level[code], number of diagnostics seen).

Raises SchemaError on anything unrecognised.
"""
failing: Counter[str] = Counter()
seen = 0
for lineno, raw in enumerate(stream, 1):
raw = raw.strip()
if not raw:
continue
try:
record = json.loads(raw)
except json.JSONDecodeError as exc:
raise SchemaError(f"line {lineno}: unparseable JSON ({exc})") from exc
if not isinstance(record, dict):
raise SchemaError(f"line {lineno}: expected a JSON object, got {type(record).__name__}")

message_type = record.get("$message_type")
if message_type not in KNOWN_MESSAGE_TYPES:
raise SchemaError(
f"line {lineno}: unrecognised $message_type {message_type!r} — "
"forge's lint output has changed; review before trusting this gate"
)

seen += 1
level = record.get("level")
if not isinstance(level, str) or not level:
raise SchemaError(f"line {lineno}: diagnostic has no `level`")
if level not in KNOWN_LEVELS:
raise SchemaError(
f"line {lineno}: unknown severity {level!r} — classify it in "
"FAIL_LEVELS or PASS_LEVELS before trusting this gate"
)
code = _diagnostic_code(record)

if level in FAIL_LEVELS:
failing[f"{level}[{code}]"] += 1
print(f"{_primary_location(record)}: {level}[{code}] {record.get('message')}")

return failing, seen


def main() -> int:
try:
failing, seen = scan(sys.stdin)
except SchemaError as exc:
print(f"::error::forge lint output failed validation: {exc}")
return 1

if failing:
total = sum(failing.values())
print(f"\n::error::forge lint reported {total} finding(s) at {sorted(FAIL_LEVELS)}")
for key, count in sorted(failing.items(), key=lambda kv: (-kv[1], kv[0])):
print(f" {count:4d} {key}")
return 1

print(f"forge lint: {seen} diagnostic(s), none at {sorted(FAIL_LEVELS)}")
return 0


if __name__ == "__main__":
raise SystemExit(main())
163 changes: 163 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
name: CI

on:
pull_request:
branches: [develop]

permissions:
contents: read

concurrency:
group: ci-${{ github.event.pull_request.number }}
cancel-in-progress: true

env:
# Versions this pipeline was measured against. See plan 2.4 / 2.9.
FOUNDRY_VERSION: v1.7.1
PIP_CONSTRAINT: .github/constraints-ci.txt

# Actions already defaults to bash on Linux, but ci-ok's result loop relies on
# word splitting, which zsh (and dash) do not do. Stated rather than assumed.
defaults:
run:
shell: bash

jobs:
solidity:
name: Solidity
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
submodules: recursive # forge-std + 3 OZ libs; see plan 2.3
fetch-depth: 1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 20
cache: npm
cache-dependency-path: |
foundry/package-lock.json
hardhat/package-lock.json
- uses: foundry-rs/foundry-toolchain@908c540300062bd5a7e473851cdb4282204cee09 # v1.9.1
with:
# Pinned rather than `stable`: forge's diagnostics are parsed by the
# lint gate and its compiler is blocking. See plan 2.9.
version: ${{ env.FOUNDRY_VERSION }}

- name: Record the toolchain actually installed
run: forge --version

# Must precede `forge test`: Upgrades.validateImplementation shells out to
# @openzeppelin/upgrades-core over FFI, and letting npx fetch it at test
# time races across the parallel tests. See plan 2.2.
- name: Install foundry npm deps
working-directory: foundry
run: npm ci

- name: forge build
working-directory: foundry
run: forge build

- name: forge test
working-directory: foundry
run: forge test

# Advisory until PR 3. `--json` writes diagnostics to stderr and cannot be
# combined with `--color`; the gate fails closed on a nonzero forge status,
# an unparseable stream, or any warning/error. See plan 2.7.
- name: forge lint (advisory)
working-directory: foundry
continue-on-error: true
run: |
set +e
forge lint --json 2> "$RUNNER_TEMP/lint.json"
forge_status=$?
set -e
echo "forge lint exit status: $forge_status"
if [ "$forge_status" -ne 0 ]; then
echo "::error::forge lint itself failed"
cat "$RUNNER_TEMP/lint.json" || true
exit 1
fi
python3 ../.github/scripts/forge_lint_gate.py < "$RUNNER_TEMP/lint.json"

- name: Install hardhat deps
working-directory: hardhat
run: npm ci

- name: hardhat compile
working-directory: hardhat
run: npx hardhat compile

- name: hardhat test
working-directory: hardhat
run: npx hardhat test

python:
name: Python
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: pip

# CPU wheel is 178 MB; the default index resolves a ~4 GB CUDA stack that
# nothing here uses. The version comes from PIP_CONSTRAINT. See plan 2.4.
- name: Install torch (CPU)
run: python -m pip install --index-url https://download.pytorch.org/whl/cpu torch

- name: Install dincli + test deps
run: python -m pip install -e ".[test]"

- name: ruff (advisory)
continue-on-error: true
run: |
python -m pip install ruff
ruff check .

# Excludes tests/dincli/ (Docker + IPFS + live chain) via the directory
# hook. pytest exits 5 if this ever selects nothing, so a broken hook
# fails rather than passes silently. See plan 2.6.
- name: pytest
run: pytest -m "not integration" -q

docs:
name: Docs
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: Relative link check
run: python .github/scripts/check_doc_links.py Documentation

# The only required status check. Keeps branch protection to one context and
# avoids the path-filter/required-check deadlock. See plan 2.8.
ci-ok:
name: CI OK
if: always()
needs: [solidity, python, docs]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Verify every job succeeded
run: |
results="${{ join(needs.*.result, ' ') }}"
echo "job results: $results"
for r in $results; do
if [ "$r" != "success" ]; then
echo "::error::a required job did not succeed ($r)"
exit 1
fi
done
echo "all jobs succeeded"
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ tasks/local/0x4657105FC932625CD289107aAE7B2174a822b709/services/__pycache__/*
/dincli/README.md
foundry/.github/*

# Bytecode anywhere. The three path-specific rules above predate this and are
# now redundant; left in place to keep this diff small. Needed because
# .github/scripts/ is imported by tests, which was not a pycache site before.
__pycache__/

# build artifacts / local envs picked up while making the tree CI-ready.
# foundry/out, foundry/cache, hardhat/artifacts, hardhat/cache and
# hardhat/typechain-types are already covered by foundry/.gitignore and
Expand Down
Loading