Skip to content

Commit 82c275e

Browse files
mikolalysenkoclaude
andcommitted
Start the bench ledger branch
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
0 parents  commit 82c275e

6 files changed

Lines changed: 175 additions & 0 deletions

File tree

‎.github/workflows/bench-ledger.yml‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: bench ledger to discussion
2+
3+
# Cloud agent sessions can't use GitHub GraphQL, so the benchmark routine
4+
# commits its progress here and this workflow mirrors it into discussion
5+
# #575 (see discussions.json). tools/mirror.py reconciles the whole branch
6+
# tip on every run, so a run cancelled by the concurrency group below loses
7+
# nothing: the next one posts whatever is missing.
8+
9+
on:
10+
push:
11+
branches: [bench/ledger]
12+
13+
permissions:
14+
contents: read
15+
discussions: write
16+
17+
concurrency:
18+
group: bench-ledger
19+
cancel-in-progress: false
20+
21+
jobs:
22+
mirror:
23+
runs-on: ubuntu-latest
24+
timeout-minutes: 10
25+
steps:
26+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
27+
with:
28+
ref: bench/ledger
29+
persist-credentials: false
30+
- name: Mirror entries and state to discussions
31+
env:
32+
GH_TOKEN: ${{ github.token }}
33+
run: python3 tools/mirror.py

‎README.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Benchmark ledger
2+
3+
This orphan branch holds the progress log of the daily `socket-patch scan`
4+
benchmark routine (crates/socket-patch-bench). Cloud sessions can't write to
5+
GitHub Discussions, so the routine commits here, and
6+
`.github/workflows/bench-ledger.yml` mirrors the branch into discussion #575,
7+
"Benchmark progress: socket-patch scan" (`discussions.json`).
8+
9+
- `entries/bench/<UTC timestamp>.md`: one file per run. Each file is posted
10+
once as a comment on the discussion.
11+
- `state/bench.md`: the scoreboard, the slow package managers, the coverage
12+
gaps and the run history. It is copied into the discussion body whenever
13+
it changes.
14+
- `history.json`: machine-readable per-run results that the routine reads
15+
back on its next run.
16+
17+
`tools/mirror.py` is idempotent. It compares the branch tip against the
18+
discussion and posts only what's missing, so a skipped or failed workflow
19+
run is repaired by the next push. Never rewrite or force-push this branch.

‎discussions.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"bench": 575}

‎entries/bench/.gitkeep‎

Whitespace-only changes.

‎history.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[]

‎tools/mirror.py‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
#!/usr/bin/env python3
2+
"""Mirror a ledger branch into GitHub Discussions.
3+
4+
Cloud agent sessions can't use GitHub GraphQL, so agents commit markdown to
5+
a ledger branch, and the workflow on that branch runs this script with the
6+
workflow token.
7+
8+
Layout (relative to the checkout):
9+
discussions.json {"<key>": <discussion number>, ...}
10+
entries/<key>/<ts>*.md each file is posted once as a discussion comment
11+
state/<key>.md replaces the discussion body when it differs
12+
13+
Idempotent: every run reconciles the whole branch tip against the
14+
discussions instead of diffing one push, so a cancelled or failed run loses
15+
nothing; the next run posts whatever is missing. An entry counts as posted
16+
if a comment carries its marker or (for comments posted before markers
17+
existed) has exactly its content.
18+
"""
19+
import argparse
20+
import json
21+
import os
22+
import subprocess
23+
import sys
24+
import tempfile
25+
26+
OWNER, _, REPO = os.environ.get("GITHUB_REPOSITORY", "SocketDev/socket-patch").partition("/")
27+
28+
29+
def gql(query, fields=None, raw=None):
30+
cmd = ["gh", "api", "graphql", "-f", f"query={query}"]
31+
for k, v in (fields or {}).items():
32+
cmd += ["-F", f"{k}={v}"]
33+
for k, v in (raw or {}).items():
34+
cmd += ["-f", f"{k}={v}"]
35+
out = subprocess.run(cmd, check=True, capture_output=True, text=True).stdout
36+
data = json.loads(out)
37+
if data.get("errors"):
38+
raise RuntimeError(data["errors"])
39+
return data["data"]
40+
41+
42+
def load_discussion(number):
43+
q = """query($o:String!,$r:String!,$n:Int!,$after:String){
44+
repository(owner:$o,name:$r){discussion(number:$n){
45+
id body
46+
comments(first:100,after:$after){pageInfo{hasNextPage endCursor} nodes{body}}}}}"""
47+
comments, after, disc = [], None, None
48+
while True:
49+
fields = {"n": number}
50+
raw = {"o": OWNER, "r": REPO}
51+
if after:
52+
raw["after"] = after
53+
disc = gql(q, fields, raw)["repository"]["discussion"]
54+
if disc is None:
55+
raise RuntimeError(f"discussion #{number} not found")
56+
page = disc["comments"]
57+
comments += [c["body"] for c in page["nodes"]]
58+
if not page["pageInfo"]["hasNextPage"]:
59+
break
60+
after = page["pageInfo"]["endCursor"]
61+
return disc["id"], disc["body"], comments
62+
63+
64+
def norm(s):
65+
return s.replace("\r\n", "\n").strip()
66+
67+
68+
def body_file(text):
69+
f = tempfile.NamedTemporaryFile("w", suffix=".md", delete=False)
70+
f.write(text)
71+
f.close()
72+
return f.name
73+
74+
75+
def main():
76+
ap = argparse.ArgumentParser()
77+
ap.add_argument("--dry-run", action="store_true")
78+
ap.add_argument("--since", default="",
79+
help="ignore entry files whose name sorts before this (e.g. 20261002T000000Z)")
80+
args = ap.parse_args()
81+
82+
with open("discussions.json") as f:
83+
mapping = json.load(f)
84+
failures = 0
85+
for key, number in sorted(mapping.items()):
86+
try:
87+
did, body, comments = load_discussion(int(number))
88+
except Exception as e: # keep going for the other keys
89+
print(f"::error::{key} #{number}: {e}")
90+
failures += 1
91+
continue
92+
posted = {norm(c) for c in comments}
93+
edir = os.path.join("entries", key)
94+
names = sorted(n for n in os.listdir(edir) if n.endswith(".md")) if os.path.isdir(edir) else []
95+
for name in names:
96+
if name < args.since:
97+
continue
98+
path = f"entries/{key}/{name}"
99+
with open(path) as f:
100+
content = f.read()
101+
marker = f"<!-- ledger-entry: {path} -->"
102+
if norm(content) in posted or any(marker in c for c in comments):
103+
continue
104+
print(f"post {key} #{number} <- {path}")
105+
if not args.dry_run:
106+
gql("mutation($d:ID!,$b:String!){addDiscussionComment(input:{discussionId:$d,body:$b}){comment{url}}}",
107+
{"b": "@" + body_file(f"{norm(content)}\n\n{marker}\n")}, {"d": did})
108+
spath = f"state/{key}.md"
109+
if os.path.isfile(spath):
110+
with open(spath) as f:
111+
state = f.read()
112+
if norm(state) != norm(body):
113+
print(f"update {key} #{number} body <- {spath}")
114+
if not args.dry_run:
115+
gql("mutation($d:ID!,$b:String!){updateDiscussion(input:{discussionId:$d,body:$b}){discussion{url}}}",
116+
{"b": "@" + spath}, {"d": did})
117+
sys.exit(1 if failures else 0)
118+
119+
120+
if __name__ == "__main__":
121+
main()

0 commit comments

Comments
 (0)