Skip to content

Commit 245fbac

Browse files
cbleckerclaude
andcommitted
Add Go-based utility to replace hack/verify-steering-election.sh
- Create new Go utility with enhanced validation capabilities - Validate YAML header format using gopkg.in/yaml.v3 library - Check filename format matches GitHub ID in header - Verify required template sections are present - Support both regular steering and GB election formats - Apply validation only to elections from 2025 forward - Update shell script to invoke Go utility Features: - Proper YAML parsing instead of regex validation - Unified validation logic for all election types - Better error messages for malformed headers - Type-safe validation of candidate bio structure 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 3f2d7cd commit 245fbac

File tree

2 files changed

+302
-25
lines changed

2 files changed

+302
-25
lines changed
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package main
18+
19+
import (
20+
"fmt"
21+
"io/ioutil"
22+
"os"
23+
"path/filepath"
24+
"regexp"
25+
"strings"
26+
27+
yaml "gopkg.in/yaml.v3"
28+
)
29+
30+
const (
31+
maxWordCount = 450
32+
recommendedWordCount = 300
33+
)
34+
35+
type ValidationError struct {
36+
File string
37+
Message string
38+
}
39+
40+
type CandidateHeader struct {
41+
Name string `yaml:"name"`
42+
ID string `yaml:"ID"`
43+
Info InfoData `yaml:"info"`
44+
}
45+
46+
type InfoData struct {
47+
Employer string `yaml:"employer"`
48+
Slack string `yaml:"slack"`
49+
}
50+
51+
52+
func main() {
53+
if len(os.Args) < 2 {
54+
fmt.Fprintf(os.Stderr, "Usage: %s <elections-path>\n", os.Args[0])
55+
os.Exit(1)
56+
}
57+
58+
electionsPath := os.Args[1]
59+
60+
bioFiles, err := findBioFiles(electionsPath)
61+
if err != nil {
62+
fmt.Fprintf(os.Stderr, "Error finding bio files: %v\n", err)
63+
os.Exit(1)
64+
}
65+
66+
var errors []ValidationError
67+
68+
for _, bioFile := range bioFiles {
69+
// Check word count
70+
wordCount, err := countWords(bioFile)
71+
if err != nil {
72+
fmt.Fprintf(os.Stderr, "Error counting words in %s: %v\n", bioFile, err)
73+
continue
74+
}
75+
76+
if wordCount > maxWordCount {
77+
errors = append(errors, ValidationError{
78+
File: bioFile,
79+
Message: fmt.Sprintf("has %d words", wordCount),
80+
})
81+
}
82+
83+
// Check filename format and GitHub ID matching
84+
if err := validateFileNameAndGitHubID(bioFile); err != nil {
85+
errors = append(errors, ValidationError{
86+
File: bioFile,
87+
Message: err.Error(),
88+
})
89+
}
90+
91+
// Check template compliance
92+
if err := validateTemplateCompliance(bioFile); err != nil {
93+
errors = append(errors, ValidationError{
94+
File: bioFile,
95+
Message: err.Error(),
96+
})
97+
}
98+
}
99+
100+
if len(errors) > 0 {
101+
for _, err := range errors {
102+
fmt.Printf("%s: %s\n", err.File, err.Message)
103+
}
104+
105+
separator := strings.Repeat("=", 68)
106+
fmt.Printf("\n%s\n", separator)
107+
fmt.Printf("%d invalid Steering Committee election bio(s) detected.\n", len(errors))
108+
fmt.Printf("Bios should be limited to around %d words, excluding headers.\n", recommendedWordCount)
109+
fmt.Printf("Bios must follow the nomination template and filename format.\n")
110+
fmt.Printf("%s\n", separator)
111+
os.Exit(1)
112+
}
113+
}
114+
115+
// findBioFiles finds all steering committee candidate bio files from 2025 forward
116+
func findBioFiles(electionsPath string) ([]string, error) {
117+
var bioFiles []string
118+
119+
// Walk through elections/steering directory specifically
120+
steeringPath := filepath.Join(electionsPath, "steering")
121+
err := filepath.Walk(steeringPath, func(path string, info os.FileInfo, err error) error {
122+
if err != nil {
123+
return err
124+
}
125+
126+
// Skip if not a regular file
127+
if !info.Mode().IsRegular() {
128+
return nil
129+
}
130+
131+
// Check if it's a candidate bio file
132+
if !strings.HasPrefix(info.Name(), "candidate-") || !strings.HasSuffix(info.Name(), ".md") {
133+
return nil
134+
}
135+
136+
// Only include elections from 2025 forward
137+
pathLower := strings.ToLower(path)
138+
includeFile := false
139+
for year := 2025; year <= 2030; year++ { // Check current year and a few years ahead
140+
yearStr := fmt.Sprintf("/%d/", year)
141+
yearGBStr := fmt.Sprintf("/%d-gb/", year)
142+
if strings.Contains(pathLower, yearStr) || strings.Contains(pathLower, yearGBStr) {
143+
includeFile = true
144+
break
145+
}
146+
}
147+
148+
if !includeFile {
149+
return nil
150+
}
151+
152+
bioFiles = append(bioFiles, path)
153+
return nil
154+
})
155+
156+
return bioFiles, err
157+
}
158+
159+
// countWords counts the number of words in a file
160+
func countWords(filename string) (int, error) {
161+
content, err := ioutil.ReadFile(filename)
162+
if err != nil {
163+
return 0, err
164+
}
165+
166+
// Split by whitespace and count non-empty strings
167+
words := regexp.MustCompile(`\s+`).Split(string(content), -1)
168+
count := 0
169+
for _, word := range words {
170+
if strings.TrimSpace(word) != "" {
171+
count++
172+
}
173+
}
174+
175+
return count, nil
176+
}
177+
178+
// validateFileNameAndGitHubID checks if filename matches format candidate-$username.md
179+
// and if the username matches the GitHub ID in the document header
180+
func validateFileNameAndGitHubID(filename string) error {
181+
// Extract filename from path
182+
base := filepath.Base(filename)
183+
184+
// Check filename format: candidate-*.md
185+
candidateRegex := regexp.MustCompile(`^candidate-([a-zA-Z0-9_-]+)\.md$`)
186+
matches := candidateRegex.FindStringSubmatch(base)
187+
if len(matches) != 2 {
188+
return fmt.Errorf("filename must follow format 'candidate-username.md'")
189+
}
190+
191+
expectedUsername := matches[1]
192+
193+
// Read file content to extract GitHub ID
194+
content, err := ioutil.ReadFile(filename)
195+
if err != nil {
196+
return fmt.Errorf("error reading file: %v", err)
197+
}
198+
199+
// Extract YAML header and parse it
200+
yamlHeader, err := extractYAMLHeader(string(content))
201+
if err != nil {
202+
return fmt.Errorf("error extracting YAML header: %v", err)
203+
}
204+
205+
// Parse the YAML to get the ID field
206+
var header map[string]interface{}
207+
if err := yaml.Unmarshal([]byte(yamlHeader), &header); err != nil {
208+
return fmt.Errorf("error parsing YAML header: %v", err)
209+
}
210+
211+
idValue, exists := header["ID"]
212+
if !exists {
213+
return fmt.Errorf("missing 'ID' field in header")
214+
}
215+
216+
actualUsername, ok := idValue.(string)
217+
if !ok {
218+
return fmt.Errorf("'ID' field must be a string")
219+
}
220+
221+
actualUsername = strings.TrimSpace(actualUsername)
222+
if actualUsername != expectedUsername {
223+
return fmt.Errorf("filename username '%s' does not match GitHub ID '%s' in header", expectedUsername, actualUsername)
224+
}
225+
226+
return nil
227+
}
228+
229+
// validateTemplateCompliance checks if the bio follows the required template structure
230+
func validateTemplateCompliance(filename string) error {
231+
content, err := ioutil.ReadFile(filename)
232+
if err != nil {
233+
return fmt.Errorf("error reading file: %v", err)
234+
}
235+
236+
contentStr := string(content)
237+
238+
// Extract YAML header between dashes
239+
yamlHeader, err := extractYAMLHeader(contentStr)
240+
if err != nil {
241+
return fmt.Errorf("error extracting YAML header: %v", err)
242+
}
243+
244+
// Parse YAML header (unified validation for all election types)
245+
var header CandidateHeader
246+
if err := yaml.Unmarshal([]byte(yamlHeader), &header); err != nil {
247+
return fmt.Errorf("invalid YAML header format: %v", err)
248+
}
249+
250+
// Validate required fields
251+
if header.Name == "" {
252+
return fmt.Errorf("missing required field: name")
253+
}
254+
if header.ID == "" {
255+
return fmt.Errorf("missing required field: ID")
256+
}
257+
if header.Info.Employer == "" {
258+
return fmt.Errorf("missing required field: info.employer")
259+
}
260+
if header.Info.Slack == "" {
261+
return fmt.Errorf("missing required field: info.slack")
262+
}
263+
264+
// Check for required sections only for regular steering elections (not GB elections)
265+
if !strings.Contains(filename, "-GB/") {
266+
requiredSections := map[string][]string{
267+
"SIGs": {"## SIGS", "## SIGs"},
268+
"What I have done": {"## What I have done"},
269+
"What I'll do": {"## What I'll do"},
270+
"Resources About Me": {"## Resources About Me"},
271+
}
272+
273+
for sectionName, alternatives := range requiredSections {
274+
found := false
275+
for _, section := range alternatives {
276+
if strings.Contains(contentStr, section) {
277+
found = true
278+
break
279+
}
280+
}
281+
if !found {
282+
return fmt.Errorf("missing required section: %s", sectionName)
283+
}
284+
}
285+
}
286+
287+
return nil
288+
}
289+
290+
// extractYAMLHeader extracts the YAML content between dash separators
291+
func extractYAMLHeader(content string) (string, error) {
292+
// Find the YAML header between dashes
293+
dashRegex := regexp.MustCompile(`(?s)^-{5,}\s*\n(.*?)\n-{5,}\s*\n`)
294+
matches := dashRegex.FindStringSubmatch(content)
295+
if len(matches) != 2 {
296+
return "", fmt.Errorf("could not find YAML header between dashes")
297+
}
298+
return matches[1], nil
299+
}

hack/verify-steering-election.sh

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,28 +18,6 @@ set -o errexit
1818
set -o nounset
1919
set -o pipefail
2020

21-
shopt -s extglob
22-
23-
# exclude bios before 2021 since some of them have more than 300 words
24-
STEERING_ELECTION_BIOS="$(pwd)/events/elections/!(2017|2018|2019|2020)/candidate-*.md"
25-
26-
invalid_bios=0
27-
break=$(printf "=%.0s" $(seq 1 68))
28-
29-
for bio in ${STEERING_ELECTION_BIOS} ; do
30-
[[ -f $bio ]] || continue
31-
word_count=$(wc -w < "$bio")
32-
if [[ ${word_count} -gt "450" ]]; then
33-
echo "${bio} has ${word_count} words."
34-
invalid_bios=$((invalid_bios+1))
35-
fi
36-
done
37-
38-
if [[ ${invalid_bios} -gt "0" ]]; then
39-
echo ""
40-
echo "${break}"
41-
echo "${invalid_bios} invalid Steering Committee election bio(s) detected."
42-
echo "Bios should be limited to around 300 words, excluding headers."
43-
echo "${break}"
44-
exit 1;
45-
fi
21+
# Build and run the Go utility
22+
cd "$(dirname "${BASH_SOURCE[0]}")/.."
23+
go run hack/verify-steering-election-tool.go elections

0 commit comments

Comments
 (0)