diff --git a/.github/scripts/generate_loc_report.py b/.github/scripts/generate_loc_report.py
new file mode 100755
index 00000000..44209a47
--- /dev/null
+++ b/.github/scripts/generate_loc_report.py
@@ -0,0 +1,191 @@
+#!/usr/bin/env python3
+"""
+Counts Rust lines of code in the ethlambda workspace via cargo-warloc and
+produces report files for Slack, Telegram, and the GitHub Actions step summary.
+
+`cargo warloc` reports per-file `main`/`tests` line counts using a Rust AST
+parser, so inline `#[cfg(test)]` blocks are correctly classified as test code.
+
+Inputs (optional):
+ loc_report.json.old Previous run's report. Used to compute deltas.
+
+Outputs:
+ loc_report.json Machine-readable report for caching.
+ loc_report_slack.json Slack Block Kit payload (daily).
+ loc_report_telegram.txt Telegram HTML body (weekly).
+ loc_report_github.txt Plain-text block for the workflow step summary.
+"""
+
+from __future__ import annotations
+
+import html
+import json
+import os
+import subprocess
+from datetime import datetime, timezone
+from pathlib import Path
+
+
+def _run(cmd: list[str]) -> str:
+ return subprocess.check_output(cmd, text=True)
+
+
+def warloc_by_file() -> dict:
+ return json.loads(_run(["cargo", "warloc", "--by-file", "-o", "json"]))
+
+
+def workspace_crates() -> list[str]:
+ md = json.loads(_run(["cargo", "metadata", "--no-deps", "--format-version", "1"]))
+ cwd = os.getcwd() + "/"
+ crates = []
+ for pkg in md["packages"]:
+ path = pkg["manifest_path"][: -len("/Cargo.toml")]
+ if path.startswith(cwd):
+ path = path[len(cwd):]
+ crates.append(path)
+ # Sort longest first so longest-prefix match wins when grouping files.
+ crates.sort(key=len, reverse=True)
+ return crates
+
+
+def group_by_crate(by_file: dict, crates: list[str]) -> dict[str, dict[str, int]]:
+ buckets = {c: {"main": 0, "tests": 0} for c in crates}
+ for raw_path, stats in by_file["files"].items():
+ path = raw_path[2:] if raw_path.startswith("./") else raw_path
+ owner = next((c for c in crates if path.startswith(c + "/")), None)
+ if owner is None:
+ continue
+ buckets[owner]["main"] += stats["main"]["code"]
+ buckets[owner]["tests"] += stats["tests"]["code"]
+ return buckets
+
+
+def format_diff(cur: int, old: int) -> str:
+ if cur > old:
+ return f"(+{cur - old})"
+ if cur < old:
+ return f"(-{old - cur})"
+ return ""
+
+
+def main() -> None:
+ by_file = warloc_by_file()
+ crates = workspace_crates()
+ buckets = group_by_crate(by_file, crates)
+
+ rows = [
+ {"path": c, "main": b["main"], "tests": b["tests"]}
+ for c, b in buckets.items()
+ ]
+ rows.sort(key=lambda r: -r["main"])
+
+ total_main = sum(r["main"] for r in rows)
+ total_tests = sum(r["tests"] for r in rows)
+ total_with_tests = total_main + total_tests
+
+ new_report = {
+ "total_main": total_main,
+ "total_tests": total_tests,
+ "total_with_tests": total_with_tests,
+ "crates": rows,
+ }
+ Path("loc_report.json").write_text(json.dumps(new_report))
+
+ # Resolve previous values (default = current → blank deltas on first run).
+ old_path = Path("loc_report.json.old")
+ if old_path.exists():
+ old = json.loads(old_path.read_text())
+ old_main = old.get("total_main", total_main)
+ old_with = old.get("total_with_tests", total_with_tests)
+ old_crates = {c["path"]: c["main"] for c in old.get("crates", [])}
+ else:
+ old_main = total_main
+ old_with = total_with_tests
+ old_crates = {r["path"]: r["main"] for r in rows}
+
+ main_diff = format_diff(total_main, old_main)
+ with_diff = format_diff(total_with_tests, old_with)
+
+ sha = os.environ.get("GITHUB_SHA") or _run(["git", "rev-parse", "HEAD"]).strip()
+ short = sha[:7]
+ date_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d")
+
+ per_crate = []
+ for r in rows:
+ old_loc = old_crates.get(r["path"], r["main"])
+ per_crate.append({
+ "path": r["path"],
+ "loc": r["main"],
+ "diff": format_diff(r["main"], old_loc),
+ })
+
+ # --- GitHub step summary -------------------------------------------------
+ gh_lines = [
+ "```",
+ f"ethlambda lines of code ({date_utc}, {short})",
+ "============================================",
+ "",
+ "Per-crate (no tests)",
+ "--------------------",
+ ]
+ gh_lines += [f"{r['path']}: {r['loc']} {r['diff']}".rstrip() for r in per_crate]
+ gh_lines += [
+ "",
+ f"Total Rust LoC (no tests): {total_main} {main_diff}".rstrip(),
+ f"Total Rust LoC (with tests): {total_with_tests} {with_diff}".rstrip(),
+ "```",
+ ]
+ Path("loc_report_github.txt").write_text("\n".join(gh_lines) + "\n")
+
+ # --- Slack Block Kit ------------------------------------------------------
+ per_crate_slack = "\n".join(
+ f"*{r['path']}*: {r['loc']} {r['diff']}".rstrip() for r in per_crate
+ )
+ totals_slack = (
+ f"*Total (no tests):* {total_main} {main_diff}".rstrip()
+ + "\n"
+ + f"*Total (with tests):* {total_with_tests} {with_diff}".rstrip()
+ )
+ slack_payload = {
+ "blocks": [
+ {"type": "header",
+ "text": {"type": "plain_text", "text": "Daily ethlambda LoC Report"}},
+ {"type": "section",
+ "text": {"type": "mrkdwn",
+ "text": f"_Date:_ {date_utc} • _Commit:_ `{short}`"}},
+ {"type": "divider"},
+ {"type": "header",
+ "text": {"type": "plain_text", "text": "Per-crate (no tests)"}},
+ {"type": "section",
+ "text": {"type": "mrkdwn", "text": per_crate_slack}},
+ {"type": "divider"},
+ {"type": "section",
+ "text": {"type": "mrkdwn", "text": totals_slack}},
+ ]
+ }
+ Path("loc_report_slack.json").write_text(json.dumps(slack_payload))
+
+ # --- Telegram (HTML parse mode) ------------------------------------------
+ def esc(s: str) -> str:
+ return html.escape(s, quote=False)
+
+ tg_lines = [
+ "Weekly ethlambda LoC Report",
+ f"Date: {date_utc} • Commit: {esc(short)}",
+ "",
+ "Per-crate (no tests)",
+ ]
+ tg_lines += [
+ f"{esc(r['path'])}: {r['loc']} {r['diff']}".rstrip()
+ for r in per_crate
+ ]
+ tg_lines += [
+ "",
+ f"Total Rust LoC (no tests): {total_main} {main_diff}".rstrip(),
+ f"Total Rust LoC (with tests): {total_with_tests} {with_diff}".rstrip(),
+ ]
+ Path("loc_report_telegram.txt").write_text("\n".join(tg_lines) + "\n")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/scripts/publish_slack.sh b/.github/scripts/publish_slack.sh
new file mode 100755
index 00000000..a99459c2
--- /dev/null
+++ b/.github/scripts/publish_slack.sh
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+#
+# POSTs a Slack Block Kit payload to an incoming webhook.
+#
+# Required env:
+# SLACK_WEBHOOK Incoming-webhook URL. Read from the env (not argv) so it
+# doesn't leak into the process list.
+#
+# Usage: publish_slack.sh
+
+set -euo pipefail
+
+PAYLOAD_FILE="${1:?payload file required}"
+
+if [[ -z "${SLACK_WEBHOOK:-}" ]]; then
+ echo "::error::SLACK_WEBHOOK resolved to an empty value — check the secret configured for this trigger (scheduled vs manual)"
+ exit 1
+fi
+
+curl --fail-with-body -X POST "$SLACK_WEBHOOK" \
+ -H 'Content-Type: application/json; charset=utf-8' \
+ --data @"$PAYLOAD_FILE"
diff --git a/.github/scripts/publish_telegram.sh b/.github/scripts/publish_telegram.sh
new file mode 100755
index 00000000..a124df62
--- /dev/null
+++ b/.github/scripts/publish_telegram.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+#
+# POSTs the contents of a file as an HTML-formatted Telegram message.
+#
+# Required env:
+# TELEGRAM_BOT_TOKEN Bot token used to authenticate the request.
+# TELEGRAM_ETHLAMBDA_CHAT_ID Destination chat ID.
+#
+# Usage: publish_telegram.sh
+
+set -euo pipefail
+
+MESSAGE_FILE="${1:?message file required}"
+
+if [[ -z "${TELEGRAM_BOT_TOKEN:-}" ]]; then
+ echo "::error::TELEGRAM_BOT_TOKEN secret is not set — skipping Telegram post"
+ exit 1
+fi
+
+if [[ -z "${TELEGRAM_ETHLAMBDA_CHAT_ID:-}" ]]; then
+ echo "::error::TELEGRAM_ETHLAMBDA_CHAT_ID resolved to an empty value — check that the appropriate secret is configured for this trigger (scheduled vs manual)"
+ exit 1
+fi
+
+curl --fail-with-body -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
+ -d chat_id="$TELEGRAM_ETHLAMBDA_CHAT_ID" \
+ -d parse_mode=HTML \
+ --data-urlencode text="$(cat "$MESSAGE_FILE")"
diff --git a/.github/workflows/daily_loc_report.yml b/.github/workflows/daily_loc_report.yml
new file mode 100644
index 00000000..0df914d2
--- /dev/null
+++ b/.github/workflows/daily_loc_report.yml
@@ -0,0 +1,104 @@
+name: Daily Lines of Code Report
+
+on:
+ schedule:
+ # Every day at UTC midnight (Slack daily, Telegram on Monday only)
+ - cron: "0 0 * * *"
+ workflow_dispatch:
+ inputs:
+ target:
+ description: "Where to post (test channel/chat or prod)"
+ required: true
+ default: "test"
+ type: choice
+ options:
+ - test
+ - prod
+ post_telegram:
+ description: "Also post to Telegram on this manual run"
+ required: false
+ default: false
+ type: boolean
+
+permissions:
+ contents: read
+ actions: write
+
+env:
+ CARGO_NET_GIT_FETCH_WITH_CLI: "true"
+ CARGO_NET_RETRY: "10"
+
+jobs:
+ loc:
+ name: Count ethlambda LoC and publish report
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout sources
+ uses: actions/checkout@v6
+
+ - name: Setup Rust
+ uses: dtolnay/rust-toolchain@master
+ with:
+ toolchain: "1.92.0"
+
+ - name: Setup cache
+ uses: Swatinem/rust-cache@v2
+
+ - name: Install cargo-warloc
+ run: cargo install cargo-warloc --locked --version 0.1.1
+
+ - name: Restore previous LoC report
+ id: cache-loc-report
+ uses: actions/cache/restore@v5
+ with:
+ path: loc_report.json
+ key: loc-report-${{ github.ref_name }}-${{ github.run_id }}
+ restore-keys: |
+ loc-report-${{ github.ref_name }}-
+
+ - name: Stash previous report as .old for delta computation
+ if: steps.cache-loc-report.outputs.cache-hit != ''
+ run: mv loc_report.json loc_report.json.old
+
+ - name: Generate LoC report
+ run: python3 .github/scripts/generate_loc_report.py
+
+ - name: Save new LoC report to cache
+ if: success()
+ uses: actions/cache/save@v5
+ with:
+ path: loc_report.json
+ key: loc-report-${{ github.ref_name }}-${{ github.run_id }}
+
+ - name: Post results to workflow summary
+ run: cat loc_report_github.txt >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Post to Slack
+ env:
+ SLACK_WEBHOOK: >-
+ ${{ (github.event_name == 'schedule' || inputs.target == 'prod')
+ && secrets.ETHLAMBDA_GENERAL_SLACK_WEBHOOK
+ || secrets.ETHLAMBDA_TEST_SLACK_WEBHOOK }}
+ run: bash .github/scripts/publish_slack.sh loc_report_slack.json
+
+ - name: Post to Telegram (weekly, or manual opt-in)
+ env:
+ TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
+ TELEGRAM_ETHLAMBDA_CHAT_ID: >-
+ ${{ (github.event_name == 'schedule' || inputs.target == 'prod')
+ && secrets.TELEGRAM_ETHLAMBDA_CHAT_ID
+ || secrets.TELEGRAM_ETHLAMBDA_TEST_CHAT_ID }}
+ run: |
+ # Scheduled runs only post to Telegram on Monday (UTC).
+ # Manual runs require post_telegram=true to opt in.
+ if [[ "${{ github.event_name }}" == "schedule" ]]; then
+ day_of_week=$(date -u +%u) # 1=Monday .. 7=Sunday
+ if [[ "$day_of_week" != "1" ]]; then
+ echo "Skipping Telegram post (scheduled run, only sent on Monday)"
+ exit 0
+ fi
+ elif [[ "${{ inputs.post_telegram }}" != "true" ]]; then
+ echo "Skipping Telegram post (manual run, post_telegram not enabled)"
+ exit 0
+ fi
+ bash .github/scripts/publish_telegram.sh loc_report_telegram.txt