Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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]