vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Give workflows and jobs a name and description
1cde8b9ea2ba1483beb90f19cde0f58aede2a661 · Keenan Nemetz · 8d ago · parent 30de4a44
Give workflows and jobs a name and description The workflows page showed the file path and job IDs, so a reader had to open the YAML comments to learn what a workflow was for. Add optional top-level and job-level name and description fields, default them to the file stem and job ID, pin them with a queued run, and show them on the workflows and run pages. Also document what the workflow format still cannot express, and state that CI jobs are independent and unordered: only a local run uses the declaration order. Part of: #138
ci/workflow.go +86−12
| @@ -42,6 +42,12 @@ const ( | |||
| 42 | 42 | // MaxArtifactPathBytes bounds one pattern. A declaration is a path, not a | |
| 43 | 43 | // manifest: the files it matches are discovered when the job runs. | |
| 44 | 44 | MaxArtifactPathBytes = 256 | |
| 45 | // MaxNameBytes bounds a display name: a workflow, a job. It is a label on a | ||
| 46 | // page, not a sentence. | ||
| 47 | MaxNameBytes = 80 | ||
| 48 | // MaxDescriptionBytes bounds a display description. One sentence a reader can | ||
| 49 | // repeat is enough to say what a workflow is for. | ||
| 50 | MaxDescriptionBytes = 500 | ||
| 45 | 51 | // DefaultTimeout applies to a step that does not set one. A hung step must not | |
| 46 | 52 | // hold a runner forever. | |
| 47 | 53 | DefaultTimeout = 10 * time.Minute | |
| @@ -99,8 +105,14 @@ type Artifact struct { | |||
| 99 | 105 | } | |
| 100 | 106 | ||
| 101 | 107 | type Job struct { | |
| 102 | ID string | ||
| 103 | Steps []Step | ||
| 108 | ID string | ||
| 109 | // Name is the display name from `name:`, or the job ID when the job does not | ||
| 110 | // declare one. The ID stays the key a run and a command line use; this is what | ||
| 111 | // a reader sees. | ||
| 112 | Name string | ||
| 113 | // Description is an optional one-line summary of what the job does. | ||
| 114 | Description string | ||
| 115 | Steps []Step | ||
| 104 | 116 | // Labels are the runner labels this job requires (`runs-on`). Empty means any | |
| 105 | 117 | // runner: a workflow that says nothing about where it runs keeps the behaviour it | |
| 106 | 118 | // had before targeting existed. A job is only leased to a runner carrying every | |
| @@ -138,6 +150,12 @@ type Pages struct { | |||
| 138 | 150 | ||
| 139 | 151 | type Workflow struct { | |
| 140 | 152 | Path string | |
| 153 | // Name is the display name from `name:`, or the workflow file's base name when | ||
| 154 | // it does not declare one. The path stays the identity; this is what a reader | ||
| 155 | // sees. | ||
| 156 | Name string | ||
| 157 | // Description is an optional one-line summary of what the workflow is for. | ||
| 158 | Description string | ||
| 141 | 159 | // BlobSHA identifies the exact file contents queued for a run, so a workflow | |
| 142 | 160 | // edited after enqueue cannot change what an already-queued job executes. | |
| 143 | 161 | BlobSHA string | |
| @@ -193,13 +211,15 @@ type rawPages struct { | |||
| 193 | 211 | } | |
| 194 | 212 | ||
| 195 | 213 | type rawJob struct { | |
| 196 | Steps []rawStep `yaml:"steps"` | ||
| 197 | RunsOn rawLabels `yaml:"runs-on"` | ||
| 198 | Image string `yaml:"image"` | ||
| 199 | ImageFile string `yaml:"image-file"` | ||
| 200 | Artifacts []rawArtifact `yaml:"artifacts"` | ||
| 201 | Pages *rawPages `yaml:"pages"` | ||
| 202 | Env map[string]string `yaml:"env"` | ||
| 214 | Name string `yaml:"name"` | ||
| 215 | Description string `yaml:"description"` | ||
| 216 | Steps []rawStep `yaml:"steps"` | ||
| 217 | RunsOn rawLabels `yaml:"runs-on"` | ||
| 218 | Image string `yaml:"image"` | ||
| 219 | ImageFile string `yaml:"image-file"` | ||
| 220 | Artifacts []rawArtifact `yaml:"artifacts"` | ||
| 221 | Pages *rawPages `yaml:"pages"` | ||
| 222 | Env map[string]string `yaml:"env"` | ||
| 203 | 223 | } | |
| 204 | 224 | ||
| 205 | 225 | // rawLabels accepts both spellings of runs-on, because both are the obvious way to | |
| @@ -230,8 +250,10 @@ func (l *rawLabels) UnmarshalYAML(node *yaml.Node) error { | |||
| 230 | 250 | } | |
| 231 | 251 | ||
| 232 | 252 | type rawWorkflow struct { | |
| 233 | Version int `yaml:"version"` | ||
| 234 | On struct { | ||
| 253 | Version int `yaml:"version"` | ||
| 254 | Name string `yaml:"name"` | ||
| 255 | Description string `yaml:"description"` | ||
| 256 | On struct { | ||
| 235 | 257 | Push struct { | |
| 236 | 258 | // Pointers, so "absent" and "empty" stay different: a workflow with tags | |
| 237 | 259 | // and no branches runs only on tags, while one with neither runs on every | |
| @@ -274,6 +296,20 @@ func Parse(file string, content string) (*Workflow, []string) { | |||
| 274 | 296 | } | |
| 275 | 297 | ||
| 276 | 298 | wf := &Workflow{Path: file, Version: rw.Version} | |
| 299 | if name, problem := displayText("name", rw.Name, MaxNameBytes); problem != "" { | ||
| 300 | bad("%s", problem) | ||
| 301 | } else if name != "" { | ||
| 302 | wf.Name = name | ||
| 303 | } else { | ||
| 304 | // A workflow whose file does not declare a name is still called something: | ||
| 305 | // the file's base name is the identity the path already gives it. | ||
| 306 | wf.Name = workflowStem(file) | ||
| 307 | } | ||
| 308 | if desc, problem := displayText("description", rw.Description, MaxDescriptionBytes); problem != "" { | ||
| 309 | bad("%s", problem) | ||
| 310 | } else { | ||
| 311 | wf.Description = desc | ||
| 312 | } | ||
| 277 | 313 | switch { | |
| 278 | 314 | case rw.On.Push.Branches != nil: | |
| 279 | 315 | wf.Branches = *rw.On.Push.Branches | |
| @@ -325,7 +361,17 @@ func Parse(file string, content string) (*Workflow, []string) { | |||
| 325 | 361 | if len(jr.Steps) > MaxSteps { | |
| 326 | 362 | bad("jobs.%s: %d steps, limit %d", id, len(jr.Steps), MaxSteps) | |
| 327 | 363 | } | |
| 328 | job := Job{ID: id} | ||
| 364 | job := Job{ID: id, Name: id} | ||
| 365 | if name, problem := displayText("jobs."+id+".name", jr.Name, MaxNameBytes); problem != "" { | ||
| 366 | bad("%s", problem) | ||
| 367 | } else if name != "" { | ||
| 368 | job.Name = name | ||
| 369 | } | ||
| 370 | if desc, problem := displayText("jobs."+id+".description", jr.Description, MaxDescriptionBytes); problem != "" { | ||
| 371 | bad("%s", problem) | ||
| 372 | } else { | ||
| 373 | job.Description = desc | ||
| 374 | } | ||
| 329 | 375 | // Every job names the environment it runs in. Either a registry image or a | |
| 330 | 376 | // definition under .vole/images; one or the other, never both and never | |
| 331 | 377 | // neither. The definition content is resolved in CheckWithImages, where the | |
| @@ -600,6 +646,34 @@ func tidyYAMLError(err error) string { | |||
| 600 | 646 | return strings.TrimSpace(msg) | |
| 601 | 647 | } | |
| 602 | 648 | ||
| 649 | // displayText trims a display name or description and reports why it cannot be | ||
| 650 | // shown on a page. An absent value is not a problem: the caller supplies the | ||
| 651 | // default, such as the job ID. | ||
| 652 | func displayText(where, value string, max int) (text, problem string) { | ||
| 653 | text = strings.TrimSpace(value) | ||
| 654 | switch { | ||
| 655 | case text == "": | ||
| 656 | return "", "" | ||
| 657 | case len(text) > max: | ||
| 658 | return "", fmt.Sprintf("%s: %d bytes, limit %d", where, len(text), max) | ||
| 659 | case strings.ContainsFunc(text, func(r rune) bool { | ||
| 660 | return r < 0x20 || r == 0x7f || r == '\u2028' || r == '\u2029' | ||
| 661 | }): | ||
| 662 | return "", fmt.Sprintf("%s: must be one line of text", where) | ||
| 663 | } | ||
| 664 | return text, "" | ||
| 665 | } | ||
| 666 | |||
| 667 | // workflowStem is the display name a workflow gets when it declares none: the | ||
| 668 | // file's base name without its .yaml or .yml extension. | ||
| 669 | func workflowStem(file string) string { | ||
| 670 | base := file | ||
| 671 | if i := strings.LastIndexByte(base, '/'); i >= 0 { | ||
| 672 | base = base[i+1:] | ||
| 673 | } | ||
| 674 | return strings.TrimSuffix(strings.TrimSuffix(base, ".yaml"), ".yml") | ||
| 675 | } | ||
| 676 | |||
| 603 | 677 | func firstLine(s string) string { | |
| 604 | 678 | if i := strings.IndexByte(s, '\n'); i >= 0 { | |
| 605 | 679 | s = s[:i] | |