Skip to content

Commit 95edd7e

Browse files
willemnealclaude
andcommitted
feat: validate registry intake forms in CI
Add the validate-registry-intake workflow plus its supporting bits: - issue-ops/parser + issue-ops/validator wiring against the kind- specific template (resolved from the issue's `registry-intake:<kind>` label). - Custom JS validators for Stellar strkeys (G…/C…, 56 char base32), sha256 wasm hashes (64 hex), semver versions, contract/wasm names (kebab-case with optional `<prefix>/`), and https URLs. - Python helper that pretty-prints the validator's error JSON into a friendly issue comment listing the offending fields. On validation success the issue is labeled `registry-intake:in-review` and a comment tells pilots how to vote. On failure the issue is labeled `issueops:validation-error` and the rendered errors are posted as a comment; the workflow re-runs whenever the issue is edited. Testable after merge: open an issue from any of the four templates, once with valid fields (expect `:in-review` + hint comment) and once with a malformed strkey/hash/semver (expect `issueops:validation-error` + rendered errors). No org PAT, no env, no signer secret needed yet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cfad448 commit 95edd7e

8 files changed

Lines changed: 281 additions & 0 deletions
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
#!/usr/bin/env python3
2+
"""Render issue-ops/validator's `errors` JSON output into a friendly markdown comment.
3+
4+
Reads `COMMENT_ERRORS` from the environment (a JSON string emitted by
5+
`issue-ops/validator@v4`'s `errors` output) and prints a markdown body to stdout.
6+
The caller pipes that into `gh issue comment --body-file -`.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import json
12+
import os
13+
import sys
14+
15+
16+
def main() -> int:
17+
raw = os.environ.get("COMMENT_ERRORS", "").strip()
18+
if not raw:
19+
print(":x: Validation failed but no error details were emitted by the validator.")
20+
return 0
21+
22+
try:
23+
errors = json.loads(raw)
24+
except json.JSONDecodeError:
25+
print(":x: Validation failed. Raw validator output:\n\n```\n" + raw + "\n```")
26+
return 0
27+
28+
if not isinstance(errors, list) or not errors:
29+
print(":x: Validation failed but the error list was empty or malformed.")
30+
return 0
31+
32+
lines = [
33+
":x: **Validation failed.** Please edit the issue to fix the problems below — the form will be re-validated automatically.",
34+
"",
35+
]
36+
for err in errors:
37+
if isinstance(err, dict):
38+
field = err.get("field") or err.get("name") or "(field)"
39+
msg = err.get("message") or err.get("error") or json.dumps(err)
40+
lines.append(f"- **`{field}`** — {msg}")
41+
else:
42+
lines.append(f"- {err}")
43+
lines.append("")
44+
lines.append(
45+
"Once you've edited the fields, the `issueops:validation-error` label will be removed and the issue will move to `registry-intake:in-review`."
46+
)
47+
48+
print("\n".join(lines))
49+
return 0
50+
51+
52+
if __name__ == "__main__":
53+
sys.exit(main())

.github/validator/config.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# issue-ops/validator@v4 config. Each entry maps an issue-form field id to a
2+
# validator script under this directory. Empty values pass — required-ness is
3+
# enforced by the form template's `validations: required: true`.
4+
#
5+
# All four registry intake forms share this config. Fields that don't exist on
6+
# a particular form are skipped automatically.
7+
validators:
8+
# Wasm/contract names (kebab-case with optional `prefix/`).
9+
- field: wasm_name
10+
script: validate-contract-name.js
11+
- field: contract_name
12+
script: validate-contract-name.js
13+
- field: new_name
14+
script: validate-contract-name.js
15+
16+
# Stellar strkey addresses — accepts G… (account) or C… (contract).
17+
- field: author
18+
script: validate-stellar-strkey.js
19+
- field: contract_address
20+
script: validate-stellar-strkey.js
21+
- field: owner
22+
script: validate-stellar-strkey.js
23+
- field: admin
24+
script: validate-stellar-strkey.js
25+
- field: deployer
26+
script: validate-stellar-strkey.js
27+
- field: new_owner
28+
script: validate-stellar-strkey.js
29+
- field: new_address
30+
script: validate-stellar-strkey.js
31+
32+
# Wasm hash — sha256 hex, 64 chars.
33+
- field: wasm_hash
34+
script: validate-wasm-hash.js
35+
36+
# Semver MAJOR.MINOR.PATCH (with optional pre-release/build metadata).
37+
- field: version
38+
script: validate-semver.js
39+
40+
# Wasm release URL — https only.
41+
- field: wasm_url
42+
script: validate-https-url.js
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
module.exports = async (field) => {
2+
if (!field || typeof field !== 'string') return 'success';
3+
const value = field.trim();
4+
if (value.length === 0) return 'success';
5+
// Optional `<prefix>/` then a kebab-or-snake segment. Each segment must
6+
// start with a lowercase letter and contain only [a-z0-9_-]. Length capped
7+
// at 64 chars total to match the registry's name storage policy.
8+
const re = /^([a-z][a-z0-9_-]*\/)?[a-z][a-z0-9_-]*$/;
9+
if (!re.test(value)) {
10+
return 'name must be kebab- or snake-case (lowercase letters, digits, `_`, `-`), optionally prefixed with `<subregistry>/`. Examples: `hello`, `unverified/my-thing`, `oz/fungible_token`.';
11+
}
12+
if (value.length > 64) {
13+
return `name exceeds 64 characters (current: ${value.length}).`;
14+
}
15+
return 'success';
16+
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
module.exports = async (field) => {
2+
if (!field || typeof field !== 'string') return 'success';
3+
const value = field.trim();
4+
if (value.length === 0) return 'success';
5+
const re = /^https:\/\/[^\s/$.?#].[^\s]*$/i;
6+
if (!re.test(value)) {
7+
return 'URL must start with `https://` and be a well-formed URL.';
8+
}
9+
return 'success';
10+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
module.exports = async (field) => {
2+
if (!field || typeof field !== 'string') return 'success';
3+
const value = field.trim();
4+
if (value.length === 0) return 'success';
5+
// Semver per https://semver.org with optional pre-release and build metadata.
6+
// The registry stores versions as opaque strings but enforces strict ordering
7+
// on publish, so an invalid semver here will be rejected later — better to
8+
// catch it at the form stage.
9+
const re = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
10+
if (!re.test(value)) {
11+
return 'version must be valid semver (`MAJOR.MINOR.PATCH`, optionally with pre-release and build metadata). Examples: `0.1.0`, `1.2.3-rc.1`, `2.0.0+build.5`.';
12+
}
13+
return 'success';
14+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
module.exports = async (field) => {
2+
if (!field || typeof field !== 'string') return 'success';
3+
const value = field.trim();
4+
if (value.length === 0) return 'success';
5+
// Stellar strkey: 56 chars, base32 alphabet (A-Z, 2-7), version byte
6+
// determines kind. We accept G (ed25519 account) or C (contract).
7+
// Muxed M and signed-payload P are intentionally rejected for these fields —
8+
// the registry methods take account/contract addresses, not muxed.
9+
const re = /^[GC][A-Z2-7]{55}$/;
10+
if (!re.test(value)) {
11+
return 'must be a 56-character Stellar strkey starting with `G` (account) or `C` (contract). Example: `GABC…56-chars` or `CABC…56-chars`.';
12+
}
13+
return 'success';
14+
};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
module.exports = async (field) => {
2+
if (!field || typeof field !== 'string') return 'success';
3+
const value = field.trim();
4+
if (value.length === 0) return 'success';
5+
// sha256 — 32 bytes, hex-encoded → 64 lowercase hex chars (case-insensitive
6+
// accepted; the on-chain registry stores it as bytes regardless).
7+
const re = /^[0-9a-fA-F]{64}$/;
8+
if (!re.test(value)) {
9+
return `wasm hash must be a 64-character hex sha256 (current length: ${value.length}). No \`0x\` prefix. Example: \`a1b2…64-chars\`.`;
10+
}
11+
return 'success';
12+
};
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
name: Validate Registry Intake
2+
3+
on:
4+
issues:
5+
types: [opened, edited, reopened]
6+
7+
permissions:
8+
contents: read
9+
issues: write
10+
11+
jobs:
12+
validate:
13+
if: contains(github.event.issue.labels.*.name, 'registry-intake')
14+
runs-on: ubuntu-latest
15+
steps:
16+
- name: Clear previous state labels
17+
# Kind subtype labels (registry-intake:publish/register/deploy/admin) are
18+
# applied by the form template itself and are NOT cleared here — they
19+
# persist for the lifetime of the issue.
20+
uses: issue-ops/labeler@v4
21+
with:
22+
action: remove
23+
issue_number: ${{ github.event.issue.number }}
24+
labels: |
25+
registry-intake:in-review
26+
registry-intake:accepted
27+
registry-intake:rejected
28+
registry-intake:submitted
29+
registry-intake:submission-failed
30+
issueops:validation-error
31+
32+
- name: Resolve form kind and template
33+
id: kind
34+
env:
35+
LABELS_JSON: ${{ toJSON(github.event.issue.labels) }}
36+
run: |
37+
set -euo pipefail
38+
KIND=$(jq -r '
39+
.[].name
40+
| select(startswith("registry-intake:"))
41+
| sub("^registry-intake:"; "")
42+
| select(. == "publish" or . == "register" or . == "deploy" or . == "admin")
43+
' <<<"$LABELS_JSON" | head -n 1)
44+
if [ -z "${KIND:-}" ]; then
45+
echo "::error::Issue has the parent `registry-intake` label but no kind subtype (publish/register/deploy/admin). The form template should set both."
46+
exit 1
47+
fi
48+
case "$KIND" in
49+
publish) TEMPLATE=registry-publish.yml ;;
50+
register) TEMPLATE=registry-register.yml ;;
51+
deploy) TEMPLATE=registry-deploy.yml ;;
52+
admin) TEMPLATE=registry-admin.yml ;;
53+
esac
54+
echo "kind=$KIND" >> "$GITHUB_OUTPUT"
55+
echo "template=$TEMPLATE" >> "$GITHUB_OUTPUT"
56+
57+
- name: Checkout repo (for validator scripts and form templates)
58+
uses: actions/checkout@v6
59+
60+
- name: Parse intake form
61+
id: parse
62+
uses: issue-ops/parser@v5
63+
with:
64+
body: ${{ github.event.issue.body }}
65+
issue-form-template: ${{ steps.kind.outputs.template }}
66+
67+
- name: Validate intake form
68+
id: validate
69+
uses: issue-ops/validator@v4
70+
with:
71+
issue-form-template: ${{ steps.kind.outputs.template }}
72+
parsed-issue-body: ${{ steps.parse.outputs.json }}
73+
add-comment: false
74+
75+
- name: Success → in-review
76+
if: steps.validate.outputs.result == 'success'
77+
uses: issue-ops/labeler@v4
78+
with:
79+
action: add
80+
create: true
81+
issue_number: ${{ github.event.issue.number }}
82+
labels: |
83+
registry-intake:in-review
84+
85+
- name: Success → comment with quorum hint
86+
if: steps.validate.outputs.result == 'success'
87+
env:
88+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
89+
run: |
90+
gh issue comment "${{ github.event.issue.number }}" -R "${{ github.repository }}" --body \
91+
":zap: Validation passed — this intake is now \`registry-intake:in-review\`.
92+
93+
**Pilots:** react with :+1: or :-1: on this issue. When you believe the quorum has been reached, comment \`.quorum-check\` to tally votes and either accept or reject.
94+
95+
The quorum policy for kind=\`${{ steps.kind.outputs.kind }}\` is in [\`.github/registry-quorum.yml\`](https://github.com/${{ github.repository }}/blob/main/.github/registry-quorum.yml)."
96+
97+
- name: Failure → validation-error label
98+
if: steps.validate.outputs.result != 'success'
99+
uses: issue-ops/labeler@v4
100+
with:
101+
action: add
102+
create: true
103+
issue_number: ${{ github.event.issue.number }}
104+
labels: |
105+
issueops:validation-error
106+
107+
- name: Failure → comment with rendered errors
108+
if: steps.validate.outputs.result != 'success'
109+
env:
110+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
111+
COMMENT_ERRORS: ${{ steps.validate.outputs.errors }}
112+
run: |
113+
python3 .github/scripts/render_validation_failure.py | \
114+
gh issue comment "${{ github.event.issue.number }}" -R "${{ github.repository }}" --body-file -
115+
116+
- name: Failure → fail the job
117+
if: steps.validate.outputs.result != 'success'
118+
run: |
119+
echo "::error::Form validation failed. See the issue comment for details."
120+
exit 1

0 commit comments

Comments
 (0)