Skip to content
Open
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
120 changes: 120 additions & 0 deletions .github/scripts/build-abi-bundle.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Builds the deterministic ABI release bundle consumed by the oak-network/sdk
# sync pipeline (see .github/workflows/release.yml).
#
# Usage: .github/scripts/build-abi-bundle.sh <tag>
# Requires: `forge build --ast` already run (artifacts/ populated WITH AST -
# contractKind is read from it), jq, tar, sha256sum.
#
# Output:
# dist/abis-<tag>.tar.gz - reproducible tarball:
# abis/{ContractName}.json { contractName, sourcePath, abi }
# sources/DataRegistryKeys.sol
# metadata.json { tag, sha, solcVersion, foundryVersion,
# contracts[], deployableContracts[] }
# dist/SHA256SUMS
set -euo pipefail

TAG="${1:?usage: build-abi-bundle.sh <tag>}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ARTIFACTS_DIR="$REPO_ROOT/artifacts"
BUNDLE_DIR="$REPO_ROOT/.abi-bundle"
DIST_DIR="$REPO_ROOT/dist"

if [[ ! -d "$ARTIFACTS_DIR" ]]; then
echo "error: $ARTIFACTS_DIR not found - run 'forge build' first" >&2
exit 1
fi

rm -rf "$BUNDLE_DIR" "$DIST_DIR"
mkdir -p "$BUNDLE_DIR/abis" "$BUNDLE_DIR/sources" "$DIST_DIR"

contracts=()
deployable=()
solc_version=""

# Every artifact whose compilation target lives under src/ (excludes test/, lib/, script/).
while IFS= read -r artifact; do
target="$(jq -r '.metadata.settings.compilationTarget | to_entries[0] | "\(.key):\(.value)"' "$artifact" 2>/dev/null || true)"
[[ "$target" == src/* ]] || continue

source_path="${target%%:*}"
contract_name="${target##*:}"

# Skip duplicate artifacts for the same source (same contract compiled under
# multiple solc versions); error on two different sources sharing one name,
# since abis/{name}.json could then hold the wrong contract.
if printf '%s\n' "${contracts[@]:-}" | grep -qx "$contract_name"; then
existing_source="$(jq -r .sourcePath "$BUNDLE_DIR/abis/$contract_name.json")"
if [[ "$existing_source" != "$source_path" ]]; then
echo "error: contract name $contract_name is defined in both $existing_source and $source_path" >&2
exit 1
fi
echo "warning: duplicate artifact for $contract_name, keeping first, skipping $artifact" >&2
continue
fi

# contractKind comes from the AST (requires forge build --ast). Only concrete,
# non-abstract contracts count as deployable - libraries and interfaces are
# bundled for their ABIs but excluded from new-contract detection.
kind_info="$(jq -r --arg n "$contract_name" \
'[.ast.nodes[]? | select(.nodeType == "ContractDefinition" and .name == $n)][0]
| if . == null then "missing" else "\(.contractKind):\(.abstract)" end' "$artifact")"
if [[ "$kind_info" == "missing" ]]; then
echo "error: no AST in artifact for $contract_name - run 'forge build --ast'" >&2
exit 1
fi

jq --arg name "$contract_name" --arg src "$source_path" -S \
'{ contractName: $name, sourcePath: $src, abi: .abi }' \
"$artifact" > "$BUNDLE_DIR/abis/$contract_name.json"

contracts+=("$contract_name")
if [[ "$kind_info" == "contract:false" && "$(jq -r '.bytecode.object' "$artifact")" != "0x" ]]; then
deployable+=("$contract_name")
fi
if [[ -z "$solc_version" ]]; then
solc_version="$(jq -r '.metadata.compiler.version' "$artifact")"
fi
done < <(find "$ARTIFACTS_DIR" -name '*.json' -path '*.sol/*' | LC_ALL=C sort)

if [[ ${#contracts[@]} -eq 0 ]]; then
echo "error: no src/ contracts found in $ARTIFACTS_DIR" >&2
exit 1
fi

cp "$REPO_ROOT/src/constants/DataRegistryKeys.sol" "$BUNDLE_DIR/sources/DataRegistryKeys.sol"

GIT_SHA="$(git -C "$REPO_ROOT" rev-parse HEAD)"
# Prefer the pinned FOUNDRY_VERSION exported by release.yml so metadata.json
# embeds the exact pin; fall back to the runtime version for local runs.
FOUNDRY_VERSION="${FOUNDRY_VERSION:-$(forge --version | head -n1)}"

# jq --args builds proper JSON arrays; guard the empty case explicitly (a bare
# printf-into-jq pipeline would turn an empty array into [""]).
contracts_json="$(jq -cn '$ARGS.positional | sort' --args "${contracts[@]}")"
if [[ ${#deployable[@]} -gt 0 ]]; then
deployable_json="$(jq -cn '$ARGS.positional | sort' --args "${deployable[@]}")"
else
deployable_json="[]"
fi

jq -n -S \
--arg tag "$TAG" \
--arg sha "$GIT_SHA" \
--arg solc "$solc_version" \
--arg foundry "$FOUNDRY_VERSION" \
--argjson contracts "$contracts_json" \
--argjson deployable "$deployable_json" \
'{ tag: $tag, sha: $sha, solcVersion: $solc, foundryVersion: $foundry,
contracts: $contracts, deployableContracts: $deployable }' \
> "$BUNDLE_DIR/metadata.json"

# Reproducible tarball: fixed order, ownership, and mtime.
tar --sort=name --owner=0 --group=0 --numeric-owner --mtime='UTC 2020-01-01' \
-czf "$DIST_DIR/abis-$TAG.tar.gz" -C "$BUNDLE_DIR" .
(cd "$DIST_DIR" && sha256sum "abis-$TAG.tar.gz" > SHA256SUMS)

echo "Bundle: $DIST_DIR/abis-$TAG.tar.gz"
echo "Contracts (${#contracts[@]}): ${contracts[*]}"
echo "Deployable (${#deployable[@]}): ${deployable[*]}"
91 changes: 91 additions & 0 deletions .github/scripts/sanitize-docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Sanitize forge-doc output: rewrite absolute machine paths in markdown links.

Some forge versions emit inter-doc links as absolute filesystem paths of the
machine that ran `forge doc` (e.g. `/Users/<name>/.../docs/src/src/interfaces/...`).
This script rewrites any link target containing `/docs/src/` to the correct
path relative to the file containing the link. Idempotent; stdlib only.

Usage: sanitize-docs.py <docs-src-dir>
Exits non-zero - without modifying any file - if a rewritten target does not
exist under <docs-src-dir> (catches upstream layout changes instead of
committing broken links). All rewrites are computed in memory first, so a
failed run leaves the tree untouched and fails the same way when re-run.
"""

from __future__ import annotations

import os
import re
import sys

# Matches links whose target is an absolute path into a forge-doc output root -
# either the committed layout (docs/src/) or the CI temp layout (.forgedoc-tmp/src/).
# Anything that slips through is caught by the grep guard in docs.yml.
LINK_PATTERN = re.compile(r"\((/[^\s)]*?/(?:docs|\.forgedoc-tmp)/src/([^\s)]+))\)")


def sanitize_file(path: str, docs_src_root: str) -> tuple[str | None, int, list[str]]:
"""Computes the rewrite of absolute /…/docs/src/… links in one file.

Does not write anything. Returns (rewritten content, or None if no links
matched; number of rewrites; rewritten targets that don't exist).
"""
with open(path, encoding="utf-8") as fh:
content = fh.read()

broken: list[str] = []
file_dir = os.path.dirname(path)

def replace(match: re.Match) -> str:
suffix = match.group(2)
target_abs = os.path.join(docs_src_root, suffix)
target_file = target_abs.split("#", 1)[0]
if not os.path.exists(target_file):
broken.append(suffix)
relative = os.path.relpath(target_abs, file_dir)
return f"({relative})"

updated, count = LINK_PATTERN.subn(replace, content)
return (updated if count > 0 else None), count, broken


def main() -> int:
if len(sys.argv) != 2:
print("usage: sanitize-docs.py <docs-src-dir>", file=sys.stderr)
return 2
docs_src_root = os.path.abspath(sys.argv[1])
if not os.path.isdir(docs_src_root):
print(f"error: not a directory: {docs_src_root}", file=sys.stderr)
return 2

total = 0
all_broken: list[str] = []
pending: list[tuple[str, str]] = []
for dirpath, _dirnames, filenames in os.walk(docs_src_root):
for filename in filenames:
if filename.endswith(".md"):
path = os.path.join(dirpath, filename)
updated, count, broken = sanitize_file(path, docs_src_root)
total += count
all_broken.extend(broken)
if updated is not None:
pending.append((path, updated))

if all_broken:
print("error: rewritten links point at missing files (no files modified):",
file=sys.stderr)
for target in sorted(set(all_broken)):
print(f" - {target}", file=sys.stderr)
return 1

for path, updated in pending:
with open(path, "w", encoding="utf-8") as fh:
fh.write(updated)

print(f"Rewrote {total} absolute link(s) under {docs_src_root}")
return 0


if __name__ == "__main__":
sys.exit(main())
86 changes: 86 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Regenerate Docs
#
# Keeps the committed forge-doc markdown under docs/src/ in sync with the
# Solidity sources on main. Regenerates into a temp dir (never straight into
# docs/ - book.toml, book.css, and solidity.min.js are customized and must be
# preserved), sanitizes machine-specific absolute link paths, and opens (or
# refreshes) an auto-PR when anything changed.
name: Regenerate Docs

on:
push:
branches: [main]
paths:
- "src/**"
- "docs/book.toml"
- "foundry.toml"
workflow_dispatch:

permissions:
contents: write
pull-requests: write

concurrency:
group: docs-regen
cancel-in-progress: true

env:
FOUNDRY_VERSION: v1.7.1 # keep in sync with release.yml

jobs:
forge-doc:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout main
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
submodules: recursive

- name: Install Foundry
uses: foundry-rs/foundry-toolchain@82dee4ba654bd2146511f85f0d013af94670c4de # v1.4.0
with:
version: ${{ env.FOUNDRY_VERSION }}

- name: Generate docs into temp dir
run: forge doc --out .forgedoc-tmp

- name: Sanitize machine-specific link paths
run: python3 .github/scripts/sanitize-docs.py .forgedoc-tmp/src

- name: Guard against surviving absolute machine paths
run: |
if grep -rEl '\]\((/Users/|/home/|/root/)' .forgedoc-tmp/src; then
echo "error: absolute machine paths survived sanitization (files listed above)" >&2
exit 1
fi

- name: Sync generated markdown into docs/src
run: |
rsync -a --delete .forgedoc-tmp/src/ docs/src/
rm -rf .forgedoc-tmp

# Known tradeoff: with the default GITHUB_TOKEN the created PR does not
# trigger pull_request workflows (no CI checks on docs PRs). If main ever
# requires status checks, add a CONTRACTS_BOT_TOKEN secret (fine-grained
# PAT, Contents + Pull requests r/w on this repo); the fallback below
# picks it up automatically.
- name: Open or refresh docs PR
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ secrets.CONTRACTS_BOT_TOKEN || github.token }}
branch: docs/regenerate
base: main
delete-branch: true
add-paths: docs/src/**
commit-message: "docs: regenerate forge doc from ${{ github.sha }}"
title: "docs: regenerate contract documentation"
labels: documentation
body: |
Auto-regenerated `forge doc` output for [`${{ github.sha }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}).

Machine-specific absolute link paths are sanitized; `docs/book.toml`,
`book.css`, and `solidity.min.js` are untouched. No-op pushes close
this PR automatically when the diff becomes empty.

_Auto-generated by the Regenerate Docs workflow._
Loading
Loading