build(agent): c3po#b883b4 iteration

This commit is contained in:
agent-b883b4bc188823a2 2026-04-25 21:09:08 +02:00
parent 5dd142ac05
commit 8fe081d762
15 changed files with 1047 additions and 2 deletions

21
.gitignore vendored Normal file
View File

@ -0,0 +1,21 @@
node_modules/
.npmrc
.env
.env.*
__tests__/
coverage/
.nyc_output/
dist/
build/
.cache/
*.log
.DS_Store
tmp/
.tmp/
__pycache__/
*.pyc
.venv/
venv/
*.egg-info/
.pytest_cache/
READY_TO_PUBLISH

32
AGENTS.md Normal file
View File

@ -0,0 +1,32 @@
# BuildLedger Agent Guide
## Architecture
- `buildledger.models` defines the canonical provenance objects.
- `buildledger.ledger` stores immutable entries in SQLite and verifies chain integrity.
- `buildledger.schemas` provides a versioned schema registry over the core models.
- `buildledger.sync` emits and applies delta packets for reconnect/resync workflows.
- `buildledger.anchors` records tamper-evident head anchors.
- `buildledger.adapters` normalizes CI context into build contracts.
- `buildledger.cli` offers small operational commands for local use.
## Tech Stack
- Python 3.11+
- `pydantic` for strict model validation
- `sqlite3` for local persistence
- `pytest` for tests
- `build` for packaging verification
## Testing
- Run `bash test.sh` from the repo root.
- `test.sh` must succeed with `pytest` and `python3 -m build`.
## Rules
- Keep ledger records append-only and hash-verified.
- Prefer canonical JSON serialization for hashes and sync packets.
- Keep adapters thin and deterministic.
- Add or update tests when changing model, storage, or sync logic.
- Preserve ASCII unless a file already requires otherwise.

View File

@ -1,3 +1,62 @@
# idea33-buildledger-verifiable-oss # BuildLedger
Source logic for Idea #33 BuildLedger is a compact provenance ledger for reproducible OSS builds.
It provides:
- strict build provenance models
- a versioned schema registry
- append-only SQLite ledger storage
- tamper-evident hash chaining
- delta-sync packets for partial connectivity
- anchor records for external attestation
- CI context adapters for GitHub Actions, GitLab CI, and CircleCI
## What It Solves
Build outputs are only useful when they can be trusted, replayed, and compared. BuildLedger captures the build contract, environment, dependency graph, artifacts, and reproducibility metadata in one validated object, then persists that object into a verifiable ledger.
## Package Layout
- `buildledger.models` - core domain objects
- `buildledger.schemas` - versioned registry and validation
- `buildledger.ledger` - SQLite-backed append-only log
- `buildledger.sync` - delta packet generation and application
- `buildledger.anchors` - anchor records for ledger heads
- `buildledger.adapters` - CI context normalization
- `buildledger.cli` - local operational commands
## Quick Start
```bash
bash test.sh
```
## Example
```python
from buildledger import SQLiteLedger
ledger = SQLiteLedger("buildledger.sqlite")
entry = ledger.append("build.contract", {"contract_id": "demo"})
ok, issues = ledger.verify_chain()
```
## CLI
```bash
python -m buildledger schema-manifest
python -m buildledger init-ledger buildledger.sqlite
python -m buildledger append-sample buildledger.sqlite
python -m buildledger verify buildledger.sqlite
```
## Testing
- `bash test.sh`
- `pytest`
- `python3 -m build`
## Status
This repository currently implements the core ledger, schema, sync, anchor, and CI-adapter foundation. Registry publishing adapters and full distributed integrations can be added on top of this base.

43
buildledger/__init__.py Normal file
View File

@ -0,0 +1,43 @@
"""BuildLedger core package."""
from .adapters import CircleCIAdapter, GitHubActionsAdapter, GitLabCIAdapter, RegistryCoordinate
from .anchors import AnchorRecord, AnchorStore
from .ledger import SQLiteLedger
from .models import (
Artifact,
BuildContract,
BuildEnvironment,
BuildLog,
BuildObject,
DependencyEdge,
DependencyGraph,
DependencyNode,
LedgerEntry,
ReproManifest,
)
from .schemas import SchemaRegistry
from .sync import DeltaPacket, SyncCursor, prepare_delta
__all__ = [
"AnchorRecord",
"AnchorStore",
"Artifact",
"BuildContract",
"BuildEnvironment",
"BuildLog",
"BuildObject",
"CircleCIAdapter",
"DeltaPacket",
"DependencyEdge",
"DependencyGraph",
"DependencyNode",
"GitHubActionsAdapter",
"GitLabCIAdapter",
"LedgerEntry",
"RegistryCoordinate",
"ReproManifest",
"SchemaRegistry",
"SQLiteLedger",
"SyncCursor",
"prepare_delta",
]

5
buildledger/__main__.py Normal file
View File

@ -0,0 +1,5 @@
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())

70
buildledger/adapters.py Normal file
View File

@ -0,0 +1,70 @@
from __future__ import annotations
from dataclasses import dataclass
from .models import BuildContract, BuildEnvironment, BuildObject, ReproManifest, sha256_text, canonical_json
@dataclass(frozen=True)
class RegistryCoordinate:
registry: str
name: str
version: str
def canonical(self) -> str:
return f"{self.registry}/{self.name}@{self.version}"
class _CIAdapter:
provider: str
def manifest_from_context(self, context: dict[str, str]) -> ReproManifest:
source_commit = context["commit"]
env_hash = sha256_text(canonical_json(context.get("environment", {})))
inputs_hash = sha256_text(canonical_json({k: context[k] for k in sorted(context) if k != "entropy_seed"}))
score = float(context.get("reproducibility_score", "1.0"))
return ReproManifest(
source_commit=source_commit,
deterministic_inputs_hash=inputs_hash,
toolchain_versions={"ci": self.provider},
env_hash=env_hash,
entropy_seed=context.get("entropy_seed"),
reproducibility_score=score,
run_count=int(context.get("run_count", "1")),
matched_artifact_bytes=int(context.get("matched_artifact_bytes", "0")),
total_artifact_bytes=int(context.get("total_artifact_bytes", "0")),
)
def contract_from_context(self, context: dict[str, str]) -> BuildContract:
manifest = self.manifest_from_context(context)
build_object = BuildObject(
source_repo=context["repo"],
commit=context["commit"],
builder=context.get("builder", self.provider),
target=context.get("target", context["repo"]),
build_tool=context.get("build_tool", self.provider),
flags=[flag for flag in context.get("flags", "").split() if flag],
)
environment = BuildEnvironment(
platform=context.get("platform", self.provider),
operating_system=context.get("os", "unknown"),
architecture=context.get("arch", "unknown"),
toolchain={k: v for k, v in context.items() if k.startswith("toolchain.")},
container_image=context.get("container_image"),
env_hash=manifest.env_hash,
variables={k: v for k, v in context.items() if k.startswith("env.")},
)
contract_id = sha256_text(canonical_json({"provider": self.provider, "repo": context["repo"], "commit": context["commit"]}))
return BuildContract(contract_id=contract_id, build_object=build_object, environment=environment, manifest=manifest)
class GitHubActionsAdapter(_CIAdapter):
provider = "github-actions"
class GitLabCIAdapter(_CIAdapter):
provider = "gitlab-ci"
class CircleCIAdapter(_CIAdapter):
provider = "circleci"

28
buildledger/anchors.py Normal file
View File

@ -0,0 +1,28 @@
from __future__ import annotations
from dataclasses import dataclass
from .ledger import SQLiteLedger
from .models import AnchorRecord
@dataclass(frozen=True)
class AnchorStore:
ledger: SQLiteLedger
def record(self, method: str, reference: str, metadata: dict[str, str] | None = None) -> AnchorRecord:
anchored_hash = self.ledger.anchor_head(method=method, reference=reference, metadata=metadata)
return AnchorRecord(method=method, reference=reference, anchored_hash=anchored_hash, metadata=metadata or {})
def all(self) -> list[AnchorRecord]:
records: list[AnchorRecord] = []
for row in self.ledger.anchors():
records.append(
AnchorRecord(
method=row["method"],
reference=row["reference"],
anchored_hash=row["anchored_hash"],
metadata={},
)
)
return records

94
buildledger/cli.py Normal file
View File

@ -0,0 +1,94 @@
from __future__ import annotations
import argparse
import json
from pathlib import Path
from .adapters import GitHubActionsAdapter
from .anchors import AnchorStore
from .ledger import SQLiteLedger
from .schemas import SchemaRegistry
from .sync import SyncCursor, apply_delta, prepare_delta
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="buildledger")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("schema-manifest")
init = sub.add_parser("init-ledger")
init.add_argument("path")
append = sub.add_parser("append-sample")
append.add_argument("path")
verify = sub.add_parser("verify")
verify.add_argument("path")
anchor = sub.add_parser("anchor")
anchor.add_argument("path")
anchor.add_argument("reference")
anchor.add_argument("--method", default="cloud-anchor")
sync = sub.add_parser("sync-plan")
sync.add_argument("path")
sync.add_argument("--sequence", type=int, default=-1)
sync.add_argument("--hash", dest="entry_hash", default="" + "0" * 64)
gh = sub.add_parser("from-github")
gh.add_argument("context_json")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.command == "schema-manifest":
print(json.dumps(SchemaRegistry.with_defaults().manifest(), indent=2, sort_keys=True))
return 0
if args.command == "init-ledger":
SQLiteLedger(Path(args.path)).close()
return 0
if args.command == "append-sample":
ledger = SQLiteLedger(Path(args.path))
ledger.append("sample", {"message": "hello buildledger"})
ledger.close()
return 0
if args.command == "verify":
ledger = SQLiteLedger(Path(args.path))
ok, issues = ledger.verify_chain()
ledger.close()
print(json.dumps({"ok": ok, "issues": issues}, indent=2))
return 0 if ok else 1
if args.command == "anchor":
ledger = SQLiteLedger(Path(args.path))
ref = AnchorStore(ledger).record(args.method, args.reference)
ledger.close()
print(ref.model_dump_json(indent=2))
return 0
if args.command == "sync-plan":
ledger = SQLiteLedger(Path(args.path))
packet = prepare_delta(ledger, SyncCursor(sequence=args.sequence, entry_hash=args.entry_hash))
ledger.close()
print(json.dumps({"base": packet.base_cursor.__dict__, "count": len(packet.entries), "target": packet.target_cursor.__dict__}, indent=2))
return 0
if args.command == "from-github":
context = json.loads(Path(args.context_json).read_text(encoding="utf-8"))
contract = GitHubActionsAdapter().contract_from_context(context)
print(contract.model_dump_json(indent=2))
return 0
return 1
if __name__ == "__main__":
raise SystemExit(main())

195
buildledger/ledger.py Normal file
View File

@ -0,0 +1,195 @@
from __future__ import annotations
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
from .models import LedgerEntry, canonical_json, sha256_text
GENESIS_HASH = "0" * 64
class LedgerError(RuntimeError):
pass
class ChainDivergenceError(LedgerError):
pass
class SQLiteLedger:
def __init__(self, path: str | Path = ":memory:") -> None:
self.path = str(path)
self._conn = sqlite3.connect(self.path)
self._conn.row_factory = sqlite3.Row
self._init_schema()
def close(self) -> None:
self._conn.close()
def __enter__(self) -> "SQLiteLedger":
return self
def __exit__(self, exc_type, exc, tb) -> None:
self.close()
def _init_schema(self) -> None:
self._conn.execute(
"""
CREATE TABLE IF NOT EXISTS entries (
sequence INTEGER PRIMARY KEY,
timestamp TEXT NOT NULL,
kind TEXT NOT NULL,
payload TEXT NOT NULL,
previous_hash TEXT NOT NULL,
entry_hash TEXT NOT NULL UNIQUE,
anchor_ref TEXT
)
"""
)
self._conn.execute(
"""
CREATE TABLE IF NOT EXISTS anchors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
method TEXT NOT NULL,
reference TEXT NOT NULL,
anchored_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
metadata TEXT NOT NULL
)
"""
)
self._conn.commit()
@property
def head(self) -> LedgerEntry | None:
row = self._conn.execute("SELECT * FROM entries ORDER BY sequence DESC LIMIT 1").fetchone()
return self._row_to_entry(row) if row else None
def append(self, kind: str, payload: dict[str, Any], anchor_ref: str | None = None) -> LedgerEntry:
last = self.head
sequence = 0 if last is None else last.sequence + 1
previous_hash = GENESIS_HASH if last is None else last.entry_hash
entry = LedgerEntry.create(sequence=sequence, kind=kind, payload=payload, previous_hash=previous_hash, anchor_ref=anchor_ref)
self._conn.execute(
"INSERT INTO entries(sequence, timestamp, kind, payload, previous_hash, entry_hash, anchor_ref) VALUES (?, ?, ?, ?, ?, ?, ?)",
(
entry.sequence,
entry.timestamp.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
entry.kind,
canonical_json(entry.payload),
entry.previous_hash,
entry.entry_hash,
entry.anchor_ref,
),
)
self._conn.commit()
return entry
def all_entries(self) -> list[LedgerEntry]:
rows = self._conn.execute("SELECT * FROM entries ORDER BY sequence ASC").fetchall()
return [self._row_to_entry(row) for row in rows]
def entry_at(self, sequence: int) -> LedgerEntry | None:
row = self._conn.execute("SELECT * FROM entries WHERE sequence = ?", (sequence,)).fetchone()
return self._row_to_entry(row) if row else None
def entries_after(self, sequence: int) -> list[LedgerEntry]:
rows = self._conn.execute("SELECT * FROM entries WHERE sequence > ? ORDER BY sequence ASC", (sequence,)).fetchall()
return [self._row_to_entry(row) for row in rows]
def verify_chain(self) -> tuple[bool, list[str]]:
issues: list[str] = []
previous_hash = GENESIS_HASH
for expected_sequence, entry in enumerate(self.all_entries()):
if entry.sequence != expected_sequence:
issues.append(f"sequence gap at {entry.sequence}")
if entry.previous_hash != previous_hash:
issues.append(f"previous hash mismatch at {entry.sequence}")
recomputed = LedgerEntry.compute_hash(
sequence=entry.sequence,
timestamp=entry.timestamp,
kind=entry.kind,
payload=entry.payload,
previous_hash=entry.previous_hash,
)
if recomputed != entry.entry_hash:
issues.append(f"entry hash mismatch at {entry.sequence}")
previous_hash = entry.entry_hash
return (not issues, issues)
def anchor_head(self, method: str, reference: str, metadata: dict[str, str] | None = None) -> str:
head = self.head
if head is None:
raise LedgerError("cannot anchor an empty ledger")
metadata_json = json.dumps(metadata or {}, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
self._conn.execute(
"INSERT INTO anchors(method, reference, anchored_hash, created_at, metadata) VALUES (?, ?, ?, ?, ?)",
(
method,
reference,
head.entry_hash,
datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
metadata_json,
),
)
self._conn.commit()
return head.entry_hash
def anchors(self) -> list[dict[str, Any]]:
rows = self._conn.execute("SELECT * FROM anchors ORDER BY id ASC").fetchall()
return [dict(row) for row in rows]
def export_json(self) -> str:
return canonical_json([entry.model_dump(mode="python") for entry in self.all_entries()])
def import_entries(self, entries: Iterable[LedgerEntry]) -> None:
for entry in entries:
self._insert_entry(entry)
self._conn.commit()
def _insert_entry(self, entry: LedgerEntry) -> None:
last = self.head
expected_sequence = 0 if last is None else last.sequence + 1
expected_previous_hash = GENESIS_HASH if last is None else last.entry_hash
if entry.sequence != expected_sequence or entry.previous_hash != expected_previous_hash:
raise ChainDivergenceError("incoming entry does not extend the current chain")
recomputed = LedgerEntry.compute_hash(
sequence=entry.sequence,
timestamp=entry.timestamp,
kind=entry.kind,
payload=entry.payload,
previous_hash=entry.previous_hash,
)
if recomputed != entry.entry_hash:
raise LedgerError("incoming entry hash is invalid")
self._conn.execute(
"INSERT INTO entries(sequence, timestamp, kind, payload, previous_hash, entry_hash, anchor_ref) VALUES (?, ?, ?, ?, ?, ?, ?)",
(
entry.sequence,
entry.timestamp.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
entry.kind,
canonical_json(entry.payload),
entry.previous_hash,
entry.entry_hash,
entry.anchor_ref,
),
)
def _row_to_entry(self, row: sqlite3.Row | None) -> LedgerEntry:
if row is None:
raise LedgerError("entry not found")
payload = json.loads(row["payload"])
timestamp = datetime.fromisoformat(row["timestamp"].replace("Z", "+00:00"))
return LedgerEntry(
sequence=row["sequence"],
timestamp=timestamp,
kind=row["kind"],
payload=payload,
previous_hash=row["previous_hash"],
entry_hash=row["entry_hash"],
anchor_ref=row["anchor_ref"],
)

229
buildledger/models.py Normal file
View File

@ -0,0 +1,229 @@
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def _to_jsonable(value: Any) -> Any:
if isinstance(value, BaseModel):
return _to_jsonable(value.model_dump(mode="python"))
if isinstance(value, datetime):
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
if isinstance(value, Enum):
return value.value
if isinstance(value, dict):
return {str(k): _to_jsonable(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_to_jsonable(v) for v in value]
if isinstance(value, set):
return sorted(_to_jsonable(v) for v in value)
return value
def canonical_json(value: Any) -> str:
return json.dumps(_to_jsonable(value), sort_keys=True, separators=(",", ":"), ensure_ascii=True)
def sha256_text(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
class BuildEnvironment(StrictModel):
platform: str
operating_system: str
architecture: str
toolchain: dict[str, str]
container_image: str | None = None
env_hash: str | None = None
variables: dict[str, str] = Field(default_factory=dict)
@field_validator("toolchain")
@classmethod
def _toolchain_not_empty(cls, value: dict[str, str]) -> dict[str, str]:
if not value:
raise ValueError("toolchain must not be empty")
return value
class BuildObject(StrictModel):
source_repo: str
commit: str
builder: str
target: str
build_tool: str
flags: list[str] = Field(default_factory=list)
created_at: datetime = Field(default_factory=utcnow)
@field_validator("commit")
@classmethod
def _commit_not_empty(cls, value: str) -> str:
if not value:
raise ValueError("commit must not be empty")
return value
class DependencyNode(StrictModel):
name: str
version: str
digest: str
kind: Literal["library", "tool", "plugin"] = "library"
source: str | None = None
class DependencyEdge(StrictModel):
source: str
target: str
relation: Literal["depends_on", "builds_with", "imports"] = "depends_on"
class DependencyGraph(StrictModel):
nodes: list[DependencyNode] = Field(default_factory=list)
edges: list[DependencyEdge] = Field(default_factory=list)
class Artifact(StrictModel):
name: str
path: str
sha256: str
size_bytes: int = Field(ge=0)
media_type: str = "application/octet-stream"
@field_validator("sha256")
@classmethod
def _sha256_format(cls, value: str) -> str:
if len(value) != 64:
raise ValueError("sha256 must be a 64-character hex digest")
int(value, 16)
return value
class BuildLog(StrictModel):
stream: Literal["stdout", "stderr"]
sha256: str
line_count: int = Field(ge=0)
excerpt: list[str] = Field(default_factory=list)
@field_validator("sha256")
@classmethod
def _sha256_format(cls, value: str) -> str:
if len(value) != 64:
raise ValueError("sha256 must be a 64-character hex digest")
int(value, 16)
return value
class ReproManifest(StrictModel):
source_commit: str
deterministic_inputs_hash: str
toolchain_versions: dict[str, str] = Field(default_factory=dict)
env_hash: str
entropy_seed: str | None = None
reproducibility_score: float = Field(ge=0.0, le=1.0)
run_count: int = Field(default=1, ge=1)
matched_artifact_bytes: int = Field(default=0, ge=0)
total_artifact_bytes: int = Field(default=0, ge=0)
class BuildContract(StrictModel):
contract_id: str
version: str = "1.0"
build_object: BuildObject
environment: BuildEnvironment
dependency_graph: DependencyGraph = Field(default_factory=DependencyGraph)
artifacts: list[Artifact] = Field(default_factory=list)
logs: list[BuildLog] = Field(default_factory=list)
manifest: ReproManifest
signature: str | None = None
notes: list[str] = Field(default_factory=list)
def canonical_payload(self) -> dict[str, Any]:
return self.model_dump(mode="python")
class LedgerEntry(StrictModel):
sequence: int = Field(ge=0)
timestamp: datetime = Field(default_factory=utcnow)
kind: str
payload: dict[str, Any]
previous_hash: str
entry_hash: str
anchor_ref: str | None = None
@field_validator("previous_hash", "entry_hash")
@classmethod
def _hash_format(cls, value: str) -> str:
if len(value) != 64:
raise ValueError("hash values must be 64-character hex digests")
int(value, 16)
return value
@classmethod
def compute_hash(
cls,
*,
sequence: int,
timestamp: datetime,
kind: str,
payload: dict[str, Any],
previous_hash: str,
) -> str:
material = canonical_json(
{
"sequence": sequence,
"timestamp": timestamp,
"kind": kind,
"payload": payload,
"previous_hash": previous_hash,
}
)
return sha256_text(material)
@classmethod
def create(
cls,
*,
sequence: int,
kind: str,
payload: dict[str, Any],
previous_hash: str,
timestamp: datetime | None = None,
anchor_ref: str | None = None,
) -> "LedgerEntry":
ts = timestamp or utcnow()
entry_hash = cls.compute_hash(
sequence=sequence,
timestamp=ts,
kind=kind,
payload=payload,
previous_hash=previous_hash,
)
return cls(
sequence=sequence,
timestamp=ts,
kind=kind,
payload=payload,
previous_hash=previous_hash,
entry_hash=entry_hash,
anchor_ref=anchor_ref,
)
class AnchorRecord(StrictModel):
method: str
reference: str
anchored_hash: str
created_at: datetime = Field(default_factory=utcnow)
metadata: dict[str, str] = Field(default_factory=dict)

59
buildledger/schemas.py Normal file
View File

@ -0,0 +1,59 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, TypeVar
from pydantic import BaseModel, ValidationError
T = TypeVar("T", bound=BaseModel)
@dataclass(frozen=True)
class SchemaKey:
name: str
version: str
class SchemaRegistry:
def __init__(self) -> None:
self._schemas: dict[SchemaKey, type[BaseModel]] = {}
def register(self, name: str, version: str, model: type[T]) -> None:
self._schemas[SchemaKey(name, version)] = model
def get(self, name: str, version: str) -> type[BaseModel]:
key = SchemaKey(name, version)
try:
return self._schemas[key]
except KeyError as exc:
raise KeyError(f"Unknown schema {name!r} version {version!r}") from exc
def validate(self, name: str, version: str, payload: Any) -> BaseModel:
model = self.get(name, version)
return model.model_validate(payload)
def versions(self, name: str) -> list[str]:
return sorted(key.version for key in self._schemas if key.name == name)
def manifest(self) -> dict[str, list[str]]:
result: dict[str, list[str]] = {}
for key in self._schemas:
result.setdefault(key.name, []).append(key.version)
return {name: sorted(versions) for name, versions in sorted(result.items())}
@classmethod
def with_defaults(cls) -> "SchemaRegistry":
from .models import Artifact, BuildContract, BuildEnvironment, BuildLog, BuildObject, DependencyGraph, DependencyNode, LedgerEntry, ReproManifest
registry = cls()
registry.register("BuildEnvironment", "1.0", BuildEnvironment)
registry.register("BuildObject", "1.0", BuildObject)
registry.register("DependencyNode", "1.0", DependencyNode)
registry.register("DependencyGraph", "1.0", DependencyGraph)
registry.register("Artifact", "1.0", Artifact)
registry.register("BuildLog", "1.0", BuildLog)
registry.register("ReproManifest", "1.0", ReproManifest)
registry.register("BuildContract", "1.0", BuildContract)
registry.register("LedgerEntry", "1.0", LedgerEntry)
return registry

70
buildledger/sync.py Normal file
View File

@ -0,0 +1,70 @@
from __future__ import annotations
from dataclasses import dataclass
from .ledger import GENESIS_HASH, ChainDivergenceError, SQLiteLedger
from .models import LedgerEntry
@dataclass(frozen=True)
class SyncCursor:
sequence: int
entry_hash: str
@dataclass(frozen=True)
class DeltaPacket:
base_cursor: SyncCursor
entries: list[LedgerEntry]
target_cursor: SyncCursor
def prepare_delta(ledger: SQLiteLedger, peer_cursor: SyncCursor | None) -> DeltaPacket:
head = ledger.head
if head is None:
cursor = SyncCursor(sequence=-1, entry_hash=GENESIS_HASH)
return DeltaPacket(base_cursor=cursor, entries=[], target_cursor=cursor)
if peer_cursor is None:
entries = ledger.all_entries()
return DeltaPacket(
base_cursor=SyncCursor(sequence=-1, entry_hash=GENESIS_HASH),
entries=entries,
target_cursor=SyncCursor(sequence=head.sequence, entry_hash=head.entry_hash),
)
if peer_cursor.sequence == -1 and peer_cursor.entry_hash == GENESIS_HASH:
return DeltaPacket(
base_cursor=peer_cursor,
entries=ledger.all_entries(),
target_cursor=SyncCursor(sequence=head.sequence, entry_hash=head.entry_hash),
)
local_anchor = ledger.entry_at(peer_cursor.sequence)
if local_anchor is None or local_anchor.entry_hash != peer_cursor.entry_hash:
raise ChainDivergenceError("peer cursor does not match this chain")
delta = ledger.entries_after(peer_cursor.sequence)
return DeltaPacket(
base_cursor=peer_cursor,
entries=delta,
target_cursor=SyncCursor(sequence=head.sequence, entry_hash=head.entry_hash),
)
def apply_delta(ledger: SQLiteLedger, packet: DeltaPacket) -> SyncCursor:
current = ledger.head
if packet.base_cursor.sequence == -1:
if current is not None:
raise ChainDivergenceError("refusing to overwrite a non-empty ledger with a genesis delta")
else:
if current is None:
raise ChainDivergenceError("cannot apply a non-empty delta to an empty ledger without genesis state")
if current.sequence != packet.base_cursor.sequence or current.entry_hash != packet.base_cursor.entry_hash:
raise ChainDivergenceError("ledger head does not match delta base cursor")
ledger.import_entries(packet.entries)
head = ledger.head
if head is None:
return SyncCursor(sequence=-1, entry_hash=GENESIS_HASH)
return SyncCursor(sequence=head.sequence, entry_hash=head.entry_hash)

24
pyproject.toml Normal file
View File

@ -0,0 +1,24 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "idea33-buildledger-verifiable-oss"
version = "0.1.0"
description = "BuildLedger captures, verifies, and syncs reproducible build provenance across registries."
readme = "README.md"
requires-python = ">=3.11"
dependencies = ["pydantic>=2.7,<3"]
[project.optional-dependencies]
test = ["pytest>=8", "build>=1.2"]
[tool.setuptools]
include-package-data = true
[tool.setuptools.packages.find]
include = ["buildledger*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]

6
test.sh Executable file
View File

@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
python3 -m pip install -e ".[test]"
pytest
python3 -m build

110
tests/test_buildledger.py Normal file
View File

@ -0,0 +1,110 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from buildledger.adapters import GitHubActionsAdapter
from buildledger.anchors import AnchorStore
from buildledger.ledger import SQLiteLedger
from buildledger.models import Artifact, BuildContract, BuildEnvironment, BuildLog, BuildObject, DependencyGraph, DependencyNode, ReproManifest
from buildledger.schemas import SchemaRegistry
from buildledger.sync import SyncCursor, apply_delta, prepare_delta
def make_contract() -> BuildContract:
build_object = BuildObject(
source_repo="https://example.com/repo.git",
commit="abc123",
builder="gha",
target="artifact.tar.gz",
build_tool="python",
flags=["--locked"],
)
environment = BuildEnvironment(
platform="linux",
operating_system="ubuntu-24.04",
architecture="x86_64",
toolchain={"python": "3.11.8"},
container_image="ghcr.io/example/build:1",
env_hash="f" * 64,
)
manifest = ReproManifest(
source_commit="abc123",
deterministic_inputs_hash="a" * 64,
toolchain_versions={"python": "3.11.8"},
env_hash="f" * 64,
reproducibility_score=1.0,
run_count=2,
matched_artifact_bytes=100,
total_artifact_bytes=100,
)
return BuildContract(
contract_id="contract-1",
build_object=build_object,
environment=environment,
dependency_graph=DependencyGraph(nodes=[DependencyNode(name="dep", version="1.0.0", digest="d" * 64)]),
artifacts=[Artifact(name="artifact", path="dist/artifact.tar.gz", sha256="b" * 64, size_bytes=100)],
logs=[BuildLog(stream="stdout", sha256="c" * 64, line_count=1, excerpt=["ok"])],
manifest=manifest,
)
def test_schema_registry_validates_contract() -> None:
registry = SchemaRegistry.with_defaults()
contract = make_contract()
loaded = registry.validate("BuildContract", "1.0", contract.model_dump(mode="python"))
assert loaded.contract_id == "contract-1"
assert registry.manifest()["BuildContract"] == ["1.0"]
def test_ledger_chain_and_anchor_round_trip(tmp_path: Path) -> None:
ledger = SQLiteLedger(tmp_path / "ledger.sqlite")
first = ledger.append("build.contract", make_contract().model_dump(mode="python"))
second = ledger.append("build.proof", {"status": "verified", "contract": first.entry_hash})
ok, issues = ledger.verify_chain()
assert ok
assert issues == []
assert second.sequence == 1
anchor = AnchorStore(ledger).record("cloud-anchor", "s3://bucket/buildledger")
assert anchor.anchored_hash == second.entry_hash
assert ledger.anchors()[0]["anchored_hash"] == second.entry_hash
def test_delta_sync_applies_to_fresh_ledger(tmp_path: Path) -> None:
source = SQLiteLedger(tmp_path / "source.sqlite")
source.append("build.contract", make_contract().model_dump(mode="python"))
source.append("build.proof", {"status": "verified"})
packet = prepare_delta(source, SyncCursor(sequence=-1, entry_hash="0" * 64))
target = SQLiteLedger(tmp_path / "target.sqlite")
cursor = apply_delta(target, packet)
assert cursor.sequence == 1
assert target.verify_chain()[0]
assert len(target.all_entries()) == 2
def test_ci_adapter_generates_contract() -> None:
context = {
"repo": "https://example.com/repo.git",
"commit": "abc123",
"builder": "gha",
"target": "artifact.tar.gz",
"build_tool": "python",
"platform": "github-actions",
"os": "ubuntu-24.04",
"arch": "x86_64",
"toolchain.python": "3.11.8",
"env.PYTHONHASHSEED": "0",
"flags": "--locked",
"run_count": "2",
"reproducibility_score": "1.0",
}
contract = GitHubActionsAdapter().contract_from_context(context)
assert contract.build_object.commit == "abc123"
assert contract.environment.variables["env.PYTHONHASHSEED"] == "0"
assert contract.manifest.run_count == 2