Skip to content

Commit 2c8c6db

Browse files
willemnealclaude
andcommitted
feat: pilot quorum + on-chain submit workflows
Add the two workflows that close the loop from in-review issue to on-chain registry transaction, plus the per-kind quorum policy and the CLI-args parser: - .github/registry-quorum.yml: per-kind `min_voters` and `require_unanimous` (admin kind requires unanimous; any 👎 blocks). Default formula mirrors SCF pg-atlas: `ups ≥ min_voters + 2*downs`. - registry-quorum-check.yml: triggered on `.quorum-check` comments, allowlisted to the `registry-pilots` team via a read-org PAT. Tallies pilot 👍/👎 reactions against the policy, swaps in-review → accepted/rejected, dispatches the on-chain workflow on accept, closes+locks the issue on reject. - registry-onchain-submit.yml: workflow_dispatch only. Re-fetches and re-validates the issue body (defense in depth), parses to CLI args via parse_intake_to_args.py, runs stellar-registry-cli from a protected GitHub Environment (`registry-testnet` / `registry-mainnet`) that gates the signer secret behind required reviewers, posts the tx hash back, labels `:submitted` or `:submission-failed`. - parse_intake_to_args.py: single source of truth for kind → CLI subcommand + flags mapping; emits a download URL when the publish method is selected (workflow fetches the wasm before invoking). Tied together, these expect the operator to have already created the `registry-pilots` team, set `READ_ORG_MEMBERS_PAT`, generated a CI keypair, called `set_manager(<CI_PUBKEY>)` once per network as registry admin, and provisioned the `registry-{testnet,mainnet}` environments with secrets and reviewers. See docs/ci-publishing.md (follow-up commit) for the runbook. Testable after merge (with bootstrap done): pilots vote on an in-review issue, one comments `.quorum-check`, the on-chain workflow pauses at the env gate, a reviewer approves, the tx hash is posted back to the issue. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c7efe7a commit 2c8c6db

4 files changed

Lines changed: 740 additions & 0 deletions

File tree

.github/registry-quorum.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Per-kind quorum policy for the registry IssueOps pipeline.
2+
#
3+
# When a `registry-pilots` member comments `.quorum-check` on an in-review issue,
4+
# `registry-quorum-check.yml` reads this file and tallies pilot reactions against
5+
# the policy below. Mirrors the formula used by SCF's pg-atlas:
6+
#
7+
# accepted iff ups >= (min_voters + 2 * downs)
8+
#
9+
# So for `min_voters: 3`: 3 ups beats 0 downs; 5 ups beats 1 down; 7 ups beats 2 downs.
10+
#
11+
# `require_unanimous: true` overrides the formula: any 👎 vote blocks acceptance,
12+
# regardless of 👍 count.
13+
#
14+
# Edits to this file go through normal PR review — keep the audit trail there.
15+
16+
defaults:
17+
team: registry-pilots # GitHub team slug under the repo's org
18+
min_voters: 3 # minimum (ups + downs) before quorum can resolve
19+
require_unanimous: false # if true, any 👎 blocks acceptance
20+
21+
# Per-kind overrides. The `kind` matches the `registry-intake:<kind>` label
22+
# applied by the issue form template.
23+
publish:
24+
min_voters: 2
25+
26+
register:
27+
min_voters: 2
28+
29+
deploy:
30+
min_voters: 3
31+
32+
admin:
33+
min_voters: 3
34+
require_unanimous: true # high-sensitivity ops: any 👎 blocks
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
#!/usr/bin/env python3
2+
"""Translate a parsed registry-intake form into stellar-registry-cli args.
3+
4+
Input via env vars:
5+
KIND — `publish` | `register` | `deploy` | `admin` (the form kind)
6+
PARSED_JSON — JSON emitted by `issue-ops/parser@v5` (the issue body fields)
7+
8+
Outputs to $GITHUB_OUTPUT:
9+
subcommand — the stellar-registry-cli subcommand to invoke
10+
args — shlex-joined string of flags. Safe to `eval` after env-var passthrough.
11+
network — `testnet` | `mainnet` (echoed for the workflow's environment selector)
12+
download_url — optional. If set, the workflow must `curl -L -o $download_dest <url>`
13+
before invoking, and the args already reference $download_dest.
14+
download_dest — optional. Path the workflow should download `download_url` to.
15+
16+
This script is the single source of truth for kind→method→flag mapping. Edits here
17+
go through normal PR review.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import json
23+
import os
24+
import shlex
25+
import sys
26+
from typing import Any
27+
28+
29+
WASM_DOWNLOAD_DEST = "/tmp/registry-intake.wasm"
30+
31+
32+
def fail(msg: str) -> "Never": # type: ignore[name-defined]
33+
print(f"::error::{msg}", file=sys.stderr)
34+
sys.exit(1)
35+
36+
37+
def emit(name: str, value: str) -> None:
38+
out_path = os.environ.get("GITHUB_OUTPUT")
39+
line = f"{name}={value}"
40+
if out_path:
41+
with open(out_path, "a", encoding="utf-8") as fh:
42+
fh.write(line + "\n")
43+
print(line)
44+
45+
46+
def get(form: dict[str, Any], key: str, *, required: bool = False) -> str:
47+
value = (form.get(key) or "").strip() if isinstance(form.get(key), str) else ""
48+
if required and not value:
49+
fail(f"missing required form field `{key}`")
50+
return value
51+
52+
53+
def build_publish(form: dict[str, Any]) -> tuple[str, list[str], str | None, str | None]:
54+
method = get(form, "method", required=True)
55+
wasm_name = get(form, "wasm_name", required=True)
56+
version = get(form, "version", required=True)
57+
wasm_hash = get(form, "wasm_hash")
58+
wasm_url = get(form, "wasm_url")
59+
author = get(form, "author")
60+
61+
if method == "publish_hash":
62+
if not wasm_hash:
63+
fail("`publish_hash` requires `wasm_hash`")
64+
args = [
65+
"--wasm-hash", wasm_hash,
66+
"--wasm-name", wasm_name,
67+
"--version", version,
68+
]
69+
if author:
70+
args += ["--author", author]
71+
return "publish-hash", args, None, None
72+
73+
if method == "publish":
74+
if not wasm_url:
75+
fail("`publish` requires `wasm_url` so the workflow can fetch the wasm bytes")
76+
args = [
77+
"--wasm", WASM_DOWNLOAD_DEST,
78+
"--wasm-name", wasm_name,
79+
"--binver", version,
80+
]
81+
if author:
82+
args += ["--author", author]
83+
return "publish", args, wasm_url, WASM_DOWNLOAD_DEST
84+
85+
fail(f"unknown publish method `{method}` (expected `publish_hash` or `publish`)")
86+
87+
88+
def build_register(form: dict[str, Any]) -> tuple[str, list[str], None, None]:
89+
contract_name = get(form, "contract_name", required=True)
90+
contract_address = get(form, "contract_address", required=True)
91+
owner = get(form, "owner")
92+
93+
args = [
94+
"--contract-name", contract_name,
95+
"--contract-address", contract_address,
96+
]
97+
if owner:
98+
args += ["--owner", owner]
99+
return "register-contract", args, None, None
100+
101+
102+
def build_deploy(form: dict[str, Any]) -> tuple[str, list[str], None, None]:
103+
wasm_name = get(form, "wasm_name", required=True)
104+
contract_name = get(form, "contract_name", required=True)
105+
admin = get(form, "admin", required=True)
106+
version = get(form, "version")
107+
deployer = get(form, "deployer")
108+
constructor_args = (form.get("constructor_args") or "").strip()
109+
110+
args = [
111+
"--wasm-name", wasm_name,
112+
"--contract-name", contract_name,
113+
]
114+
if version:
115+
args += ["--version", version]
116+
if deployer:
117+
args += ["--deployer", deployer]
118+
119+
args.append("--")
120+
args.append(f"--admin={admin}")
121+
# Each non-blank line of constructor_args is treated as one extra `--key=value`
122+
# arg passed to `__constructor`. Submitters write them one per line.
123+
for line in constructor_args.splitlines():
124+
line = line.strip()
125+
if line:
126+
args.append(line)
127+
128+
return "deploy", args, None, None
129+
130+
131+
def build_admin(form: dict[str, Any]) -> tuple[str, list[str], None, None]:
132+
method = get(form, "method", required=True)
133+
contract_name = get(form, "contract_name", required=True)
134+
135+
if method == "update_contract_owner":
136+
new_owner = get(form, "new_owner", required=True)
137+
return "update-contract-owner", [
138+
"--contract-name", contract_name,
139+
"--new-owner", new_owner,
140+
], None, None
141+
142+
if method == "update_contract_address":
143+
new_address = get(form, "new_address", required=True)
144+
return "update-contract-address", [
145+
"--contract-name", contract_name,
146+
"--new-address", new_address,
147+
], None, None
148+
149+
if method == "rename_contract":
150+
new_name = get(form, "new_name", required=True)
151+
return "rename-contract", [
152+
"--contract-name", contract_name,
153+
"--new-name", new_name,
154+
], None, None
155+
156+
if method == "upgrade_contract":
157+
wasm_name = get(form, "wasm_name", required=True)
158+
version = get(form, "version")
159+
args = [
160+
"--contract-name", contract_name,
161+
"--wasm-name", wasm_name,
162+
]
163+
if version:
164+
args += ["--version", version]
165+
return "upgrade", args, None, None
166+
167+
fail(f"unknown admin method `{method}`")
168+
169+
170+
def main() -> int:
171+
kind = (os.environ.get("KIND") or "").strip()
172+
raw = (os.environ.get("PARSED_JSON") or "").strip()
173+
if not kind:
174+
fail("KIND env var is required")
175+
if not raw:
176+
fail("PARSED_JSON env var is required")
177+
178+
try:
179+
form = json.loads(raw)
180+
except json.JSONDecodeError as exc:
181+
fail(f"PARSED_JSON is not valid JSON: {exc}")
182+
183+
if not isinstance(form, dict):
184+
fail("PARSED_JSON must decode to an object")
185+
186+
network = get(form, "network", required=True)
187+
if network not in {"testnet", "mainnet"}:
188+
fail(f"network must be `testnet` or `mainnet`, got `{network}`")
189+
190+
builders = {
191+
"publish": build_publish,
192+
"register": build_register,
193+
"deploy": build_deploy,
194+
"admin": build_admin,
195+
}
196+
if kind not in builders:
197+
fail(f"unknown kind `{kind}` (expected one of {sorted(builders)})")
198+
199+
subcommand, args, download_url, download_dest = builders[kind](form)
200+
201+
emit("subcommand", subcommand)
202+
emit("args", shlex.join(args))
203+
emit("network", network)
204+
emit("download_url", download_url or "")
205+
emit("download_dest", download_dest or "")
206+
return 0
207+
208+
209+
if __name__ == "__main__":
210+
sys.exit(main())

0 commit comments

Comments
 (0)