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.
Add event, schedule and manual workflow triggers
eb07ff5696e0d83201215966e3ea1e6a24467581 · Keenan Nemetz · 8d ago · parent 1cde8b9e
Add event, schedule and manual workflow triggers Workflows can now react to change requests, issues, comments, cron schedules and manual runs, and a push trigger can filter by the paths it changed. Branch and tag patterns gain ! exclusion. Event runs are pinned with a JSON payload and the runner exposes it as VOLE_EVENT, VOLE_EVENT_ACTION and VOLE_EVENT_PATH. Change-request runs check out the base tip and carry the head and changed files, so an event never executes an unapproved contributor commit. Workflow dispatch resolves typed inputs and is available from the workflows page and `vole workflow dispatch`. Deduplication now includes an occurrence so the same commit can run for a later event or schedule slot while a push remains idempotent. Part of: #138
ci/workflow.go +792−32
| @@ -16,6 +16,7 @@ import ( | |||
| 16 | 16 | "path/filepath" | |
| 17 | 17 | "regexp" | |
| 18 | 18 | "sort" | |
| 19 | "strconv" | ||
| 19 | 20 | "strings" | |
| 20 | 21 | "time" | |
| 21 | 22 | ||
| @@ -48,6 +49,20 @@ const ( | |||
| 48 | 49 | // MaxDescriptionBytes bounds a display description. One sentence a reader can | |
| 49 | 50 | // repeat is enough to say what a workflow is for. | |
| 50 | 51 | MaxDescriptionBytes = 500 | |
| 52 | // MaxPaths bounds the path filters a push trigger may declare. A workflow that | ||
| 53 | // needs more than a handful of patterns is describing a directory and should say | ||
| 54 | // so with a `**` pattern. | ||
| 55 | MaxPaths = 20 | ||
| 56 | // MaxSchedules bounds the cron entries on one workflow. A schedule is an | ||
| 57 | // expectation the runner pool has to meet, not a place to keep a list. | ||
| 58 | MaxSchedules = 5 | ||
| 59 | // MaxDispatchInputs bounds the inputs a manual run may declare. | ||
| 60 | MaxDispatchInputs = 10 | ||
| 61 | // MaxDispatchValueBytes bounds one manual input value. | ||
| 62 | MaxDispatchValueBytes = 4 << 10 | ||
| 63 | // MaxEventPayloadBytes bounds the event JSON pinned with a run. It is read as | ||
| 64 | // data from a mounted file, never interpolated into a step. | ||
| 65 | MaxEventPayloadBytes = 64 << 10 | ||
| 51 | 66 | // DefaultTimeout applies to a step that does not set one. A hung step must not | |
| 52 | 67 | // hold a runner forever. | |
| 53 | 68 | DefaultTimeout = 10 * time.Minute | |
| @@ -63,18 +78,37 @@ var ( | |||
| 63 | 78 | // runner says it is with --label. Lowercase because a label is an identity, and | |
| 64 | 79 | // `Linux` and `linux` being different machines is a support question nobody wants. | |
| 65 | 80 | labelRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`) | |
| 81 | // dispatchInputRE is a name that can reach a shell as a variable when a step | ||
| 82 | // chooses to export the inputs it reads from the event payload. | ||
| 83 | dispatchInputRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_-]{0,31}$`) | ||
| 84 | // pathGlob allows the two wildcards a path filter needs and nothing that could | ||
| 85 | // be read as a revision or escape the tree. | ||
| 86 | pathGlob = regexp.MustCompile(`^[A-Za-z0-9*?\[\]!._/-]+$`) | ||
| 66 | 87 | // reservedEnv is what the sandbox itself sets. A job that could replace these | |
| 67 | 88 | // could make the container lie about being in CI or move HOME out of the writable | |
| 68 | // path, so they are refused rather than overridden. | ||
| 89 | // path, so they are refused rather than overridden. The event variables are set | ||
| 90 | // by the runner from the pinned event, never by the workflow. | ||
| 69 | 91 | reservedEnv = map[string]bool{ | |
| 70 | 92 | "CI": true, | |
| 71 | 93 | "VOLE_CI": true, | |
| 94 | "VOLE_EVENT": true, | ||
| 95 | "VOLE_EVENT_ACTION": true, | ||
| 96 | "VOLE_EVENT_PATH": true, | ||
| 72 | 97 | "GIT_TERMINAL_PROMPT": true, | |
| 73 | 98 | "HOME": true, | |
| 74 | 99 | "TMPDIR": true, | |
| 75 | 100 | } | |
| 76 | 101 | ) | |
| 77 | 102 | ||
| 103 | var ( | ||
| 104 | changeRequestActions = []string{"opened", "updated", "ready", "closed", "merged"} | ||
| 105 | defaultChangeRequestActions = []string{"opened", "updated", "ready"} | ||
| 106 | issueActions = []string{"opened", "edited", "closed", "reopened"} | ||
| 107 | defaultIssueActions = []string{"opened"} | ||
| 108 | commentActions = []string{"created", "edited", "deleted"} | ||
| 109 | defaultCommentActions = []string{"created"} | ||
| 110 | ) | ||
| 111 | |||
| 78 | 112 | // ValidLabel reports whether s can be a runner label. It is exported so the runner | |
| 79 | 113 | // refuses a bad --label at startup with the same rule the workflow validator applies: | |
| 80 | 114 | // a label that can never be stored would otherwise be a machine that silently never | |
| @@ -148,6 +182,36 @@ type Pages struct { | |||
| 148 | 182 | Path string | |
| 149 | 183 | } | |
| 150 | 184 | ||
| 185 | // EventTrigger is the action list for a non-push trigger. An absent list is the | ||
| 186 | // trigger's default, which is documented in the reference: the common actions, | ||
| 187 | // not every action the server can report. | ||
| 188 | type EventTrigger struct { | ||
| 189 | Types []string | ||
| 190 | } | ||
| 191 | |||
| 192 | // Schedule is one cron entry. Times are UTC, and the field syntax is the usual | ||
| 193 | // five fields: minute, hour, day of month, month, day of week. | ||
| 194 | type Schedule struct { | ||
| 195 | Cron string | ||
| 196 | } | ||
| 197 | |||
| 198 | // DispatchInput is one typed input a manual run may carry. The type decides what | ||
| 199 | // value the server accepts; the workflow step sees the resolved value in the event | ||
| 200 | // payload, never in an interpolated shell string. | ||
| 201 | type DispatchInput struct { | ||
| 202 | Description string | ||
| 203 | Type string // string, boolean, number, choice | ||
| 204 | Required bool | ||
| 205 | Default string | ||
| 206 | DefaultSet bool | ||
| 207 | Options []string // for type choice | ||
| 208 | } | ||
| 209 | |||
| 210 | // Dispatch declares that a workflow can be run by hand, with typed inputs. | ||
| 211 | type Dispatch struct { | ||
| 212 | Inputs map[string]DispatchInput | ||
| 213 | } | ||
| 214 | |||
| 151 | 215 | type Workflow struct { | |
| 152 | 216 | Path string | |
| 153 | 217 | // Name is the display name from `name:`, or the workflow file's base name when | |
| @@ -161,20 +225,27 @@ type Workflow struct { | |||
| 161 | 225 | BlobSHA string | |
| 162 | 226 | Version int | |
| 163 | 227 | Branches []string | |
| 228 | // Paths and PathsIgnore filter a push by the files it changes. An empty Paths | ||
| 229 | // means every path; PathsIgnore removes paths from that set. A push with no | ||
| 230 | // changed files matches neither filter. | ||
| 231 | Paths []string | ||
| 232 | PathsIgnore []string | ||
| 164 | 233 | // Tags are the tag patterns that trigger this ci. A tag run is how a | |
| 165 | 234 | // release build happens: the tag names what is being released. | |
| 166 | 235 | Tags []string | |
| 167 | Jobs []Job | ||
| 236 | // ChangeRequest, Issues and Comments are metadata triggers. They are nil when | ||
| 237 | // the workflow does not listen to that event. | ||
| 238 | ChangeRequest *EventTrigger | ||
| 239 | Issues *EventTrigger | ||
| 240 | Comments *EventTrigger | ||
| 241 | Schedules []Schedule | ||
| 242 | Dispatch *Dispatch | ||
| 243 | Jobs []Job | ||
| 168 | 244 | } | |
| 169 | 245 | ||
| 170 | 246 | // AppliesToTag reports whether this workflow should run for a tag push. | |
| 171 | 247 | func (w *Workflow) AppliesToTag(tag string) bool { | |
| 172 | for _, pattern := range w.Tags { | ||
| 173 | if ok, err := path.Match(pattern, tag); err == nil && ok { | ||
| 174 | return true | ||
| 175 | } | ||
| 176 | } | ||
| 177 | return false | ||
| 248 | return matchRefPatterns(w.Tags, tag, false) | ||
| 178 | 249 | } | |
| 179 | 250 | ||
| 180 | 251 | // Applies reports whether this workflow should run for a push to branch. | |
| @@ -183,12 +254,131 @@ func (w *Workflow) AppliesToTag(tag string) bool { | |||
| 183 | 254 | // as one path segment, which silently excluded contributor branches such as | |
| 184 | 255 | // contrib/<account-name>/fix. Other patterns keep path.Match's one-segment rule, so | |
| 185 | 256 | // "release/*" does not accidentally become "release/anything/at/all". | |
| 257 | // | ||
| 258 | // A leading ! excludes what an earlier pattern included. The last matching pattern | ||
| 259 | // wins, so ["*", "!main"] runs everywhere except main. | ||
| 186 | 260 | func (w *Workflow) Applies(branch string) bool { | |
| 187 | for _, pattern := range w.Branches { | ||
| 188 | if pattern == "*" { | ||
| 261 | return matchRefPatterns(w.Branches, branch, true) | ||
| 262 | } | ||
| 263 | |||
| 264 | // AppliesToPush reports whether this workflow should run for a branch or tag push | ||
| 265 | // with the given changed paths. Exactly one of branch and tag is expected. | ||
| 266 | func (w *Workflow) AppliesToPush(branch, tag string, changed []string) bool { | ||
| 267 | switch { | ||
| 268 | case branch != "" && tag == "": | ||
| 269 | if !w.Applies(branch) { | ||
| 270 | return false | ||
| 271 | } | ||
| 272 | case tag != "" && branch == "": | ||
| 273 | if !w.AppliesToTag(tag) { | ||
| 274 | return false | ||
| 275 | } | ||
| 276 | default: | ||
| 277 | return false | ||
| 278 | } | ||
| 279 | return w.PathsMatch(changed) | ||
| 280 | } | ||
| 281 | |||
| 282 | // AppliesToEvent reports whether this workflow listens to one non-push event. | ||
| 283 | func (w *Workflow) AppliesToEvent(kind, action string) bool { | ||
| 284 | switch kind { | ||
| 285 | case "change_request": | ||
| 286 | return w.ChangeRequest != nil && containsString(w.ChangeRequest.Types, action) | ||
| 287 | case "issue": | ||
| 288 | return w.Issues != nil && containsString(w.Issues.Types, action) | ||
| 289 | case "comment": | ||
| 290 | return w.Comments != nil && containsString(w.Comments.Types, action) | ||
| 291 | case "schedule": | ||
| 292 | return len(w.Schedules) > 0 | ||
| 293 | case "workflow_dispatch": | ||
| 294 | return w.Dispatch != nil | ||
| 295 | } | ||
| 296 | return false | ||
| 297 | } | ||
| 298 | |||
| 299 | // PathsMatch applies the push path filters to the paths a push changed. An ignored | ||
| 300 | // path is removed before the allow list is consulted, so an ignored file cannot | ||
| 301 | // make a workflow run; when every changed path is ignored, nothing runs. A push | ||
| 302 | // with no changed files matches only a workflow with no path filters. | ||
| 303 | func (w *Workflow) PathsMatch(changed []string) bool { | ||
| 304 | if len(w.Paths) == 0 && len(w.PathsIgnore) == 0 { | ||
| 305 | return true | ||
| 306 | } | ||
| 307 | for _, changedPath := range changed { | ||
| 308 | if matchAnyPath(w.PathsIgnore, changedPath) { | ||
| 309 | continue | ||
| 310 | } | ||
| 311 | if len(w.Paths) == 0 || matchAnyPath(w.Paths, changedPath) { | ||
| 312 | return true | ||
| 313 | } | ||
| 314 | } | ||
| 315 | return false | ||
| 316 | } | ||
| 317 | |||
| 318 | func matchRefPatterns(patterns []string, name string, starMatchesAll bool) bool { | ||
| 319 | matched := false | ||
| 320 | for _, raw := range patterns { | ||
| 321 | pattern := strings.TrimSpace(raw) | ||
| 322 | negated := strings.HasPrefix(pattern, "!") | ||
| 323 | if negated { | ||
| 324 | pattern = strings.TrimPrefix(pattern, "!") | ||
| 325 | } | ||
| 326 | if pattern == "" { | ||
| 327 | continue | ||
| 328 | } | ||
| 329 | if starMatchesAll && pattern == "*" { | ||
| 330 | matched = !negated | ||
| 331 | continue | ||
| 332 | } | ||
| 333 | if ok, err := path.Match(pattern, name); err == nil && ok { | ||
| 334 | matched = !negated | ||
| 335 | } | ||
| 336 | } | ||
| 337 | return matched | ||
| 338 | } | ||
| 339 | |||
| 340 | func matchAnyPath(patterns []string, name string) bool { | ||
| 341 | for _, pattern := range patterns { | ||
| 342 | if matchPathPattern(pattern, name) { | ||
| 189 | 343 | return true | |
| 190 | 344 | } | |
| 191 | if ok, err := path.Match(pattern, branch); err == nil && ok { | ||
| 345 | } | ||
| 346 | return false | ||
| 347 | } | ||
| 348 | |||
| 349 | // matchPathPattern matches one path pattern. A single * stays inside one segment; | ||
| 350 | // ** spans zero or more segments, so docs/** covers the directory itself and every | ||
| 351 | // file under it. | ||
| 352 | func matchPathPattern(pattern, name string) bool { | ||
| 353 | return matchPathSegments(strings.Split(pattern, "/"), strings.Split(name, "/")) | ||
| 354 | } | ||
| 355 | |||
| 356 | func matchPathSegments(pattern, name []string) bool { | ||
| 357 | for len(pattern) > 0 { | ||
| 358 | if pattern[0] == "**" { | ||
| 359 | if matchPathSegments(pattern[1:], name) { | ||
| 360 | return true | ||
| 361 | } | ||
| 362 | if len(name) == 0 { | ||
| 363 | return false | ||
| 364 | } | ||
| 365 | name = name[1:] | ||
| 366 | continue | ||
| 367 | } | ||
| 368 | if len(name) == 0 { | ||
| 369 | return false | ||
| 370 | } | ||
| 371 | if ok, err := path.Match(pattern[0], name[0]); err != nil || !ok { | ||
| 372 | return false | ||
| 373 | } | ||
| 374 | pattern, name = pattern[1:], name[1:] | ||
| 375 | } | ||
| 376 | return len(name) == 0 | ||
| 377 | } | ||
| 378 | |||
| 379 | func containsString(list []string, want string) bool { | ||
| 380 | for _, value := range list { | ||
| 381 | if value == want { | ||
| 192 | 382 | return true | |
| 193 | 383 | } | |
| 194 | 384 | } | |
| @@ -210,6 +400,128 @@ type rawPages struct { | |||
| 210 | 400 | Path string `yaml:"path"` | |
| 211 | 401 | } | |
| 212 | 402 | ||
| 403 | // rawEventTrigger accepts `change-request:`, `issue:` and `comment:` as either an | ||
| 404 | // empty value (all the default actions) or a mapping with a types list. It rejects | ||
| 405 | // unknown keys itself because a custom unmarshaler is what makes the two spellings | ||
| 406 | // possible, and the outer decoder's KnownFields cannot see inside the node. | ||
| 407 | type rawEventTrigger struct { | ||
| 408 | Set bool | ||
| 409 | Types []string | ||
| 410 | } | ||
| 411 | |||
| 412 | func (t *rawEventTrigger) UnmarshalYAML(node *yaml.Node) error { | ||
| 413 | t.Set = true | ||
| 414 | if node.Tag == "!!null" { | ||
| 415 | return nil | ||
| 416 | } | ||
| 417 | if node.Kind != yaml.MappingNode { | ||
| 418 | return fmt.Errorf("expects a mapping like {types: [opened]} or an empty value") | ||
| 419 | } | ||
| 420 | for i := 0; i < len(node.Content); i += 2 { | ||
| 421 | key := node.Content[i].Value | ||
| 422 | if key != "types" { | ||
| 423 | return fmt.Errorf("field %s is not a known key here", key) | ||
| 424 | } | ||
| 425 | if err := node.Content[i+1].Decode(&t.Types); err != nil { | ||
| 426 | return err | ||
| 427 | } | ||
| 428 | } | ||
| 429 | return nil | ||
| 430 | } | ||
| 431 | |||
| 432 | type rawSchedule struct { | ||
| 433 | Cron string `yaml:"cron"` | ||
| 434 | } | ||
| 435 | |||
| 436 | // rawDispatch accepts `workflow-dispatch:` as an empty value or a mapping with | ||
| 437 | // inputs. The same strict-key rule as rawEventTrigger applies. | ||
| 438 | type rawDispatch struct { | ||
| 439 | Set bool | ||
| 440 | Inputs map[string]rawDispatchInput | ||
| 441 | } | ||
| 442 | |||
| 443 | type rawDispatchInput struct { | ||
| 444 | Description string | ||
| 445 | Type string | ||
| 446 | Required bool | ||
| 447 | Default string | ||
| 448 | DefaultSet bool | ||
| 449 | Options []string | ||
| 450 | } | ||
| 451 | |||
| 452 | func (t *rawDispatch) UnmarshalYAML(node *yaml.Node) error { | ||
| 453 | t.Set = true | ||
| 454 | if node.Tag == "!!null" { | ||
| 455 | return nil | ||
| 456 | } | ||
| 457 | if node.Kind != yaml.MappingNode { | ||
| 458 | return fmt.Errorf("expects a mapping like {inputs: {...}} or an empty value") | ||
| 459 | } | ||
| 460 | for i := 0; i < len(node.Content); i += 2 { | ||
| 461 | key := node.Content[i].Value | ||
| 462 | if key != "inputs" { | ||
| 463 | return fmt.Errorf("field %s is not a known key here", key) | ||
| 464 | } | ||
| 465 | value := node.Content[i+1] | ||
| 466 | if value.Tag == "!!null" { | ||
| 467 | continue | ||
| 468 | } | ||
| 469 | if value.Kind != yaml.MappingNode { | ||
| 470 | return fmt.Errorf("inputs expects a mapping of names to input definitions") | ||
| 471 | } | ||
| 472 | t.Inputs = map[string]rawDispatchInput{} | ||
| 473 | for j := 0; j < len(value.Content); j += 2 { | ||
| 474 | name := value.Content[j].Value | ||
| 475 | in, err := decodeDispatchInput(value.Content[j+1]) | ||
| 476 | if err != nil { | ||
| 477 | return fmt.Errorf("inputs.%s: %w", name, err) | ||
| 478 | } | ||
| 479 | t.Inputs[name] = in | ||
| 480 | } | ||
| 481 | } | ||
| 482 | return nil | ||
| 483 | } | ||
| 484 | |||
| 485 | func decodeDispatchInput(node *yaml.Node) (rawDispatchInput, error) { | ||
| 486 | var in rawDispatchInput | ||
| 487 | if node.Tag == "!!null" { | ||
| 488 | return in, nil | ||
| 489 | } | ||
| 490 | if node.Kind != yaml.MappingNode { | ||
| 491 | return in, fmt.Errorf("expects a mapping of description, type, required, default and options") | ||
| 492 | } | ||
| 493 | for i := 0; i < len(node.Content); i += 2 { | ||
| 494 | key := node.Content[i].Value | ||
| 495 | value := node.Content[i+1] | ||
| 496 | switch key { | ||
| 497 | case "description": | ||
| 498 | if err := value.Decode(&in.Description); err != nil { | ||
| 499 | return in, err | ||
| 500 | } | ||
| 501 | case "type": | ||
| 502 | if err := value.Decode(&in.Type); err != nil { | ||
| 503 | return in, err | ||
| 504 | } | ||
| 505 | case "required": | ||
| 506 | if err := value.Decode(&in.Required); err != nil { | ||
| 507 | return in, err | ||
| 508 | } | ||
| 509 | case "default": | ||
| 510 | // The value is kept as the text YAML saw, so a quoted "false" and a bare | ||
| 511 | // false both reach the type check as the same string. | ||
| 512 | in.Default = value.Value | ||
| 513 | in.DefaultSet = true | ||
| 514 | case "options": | ||
| 515 | if err := value.Decode(&in.Options); err != nil { | ||
| 516 | return in, err | ||
| 517 | } | ||
| 518 | default: | ||
| 519 | return in, fmt.Errorf("field %s is not a known key here", key) | ||
| 520 | } | ||
| 521 | } | ||
| 522 | return in, nil | ||
| 523 | } | ||
| 524 | |||
| 213 | 525 | type rawJob struct { | |
| 214 | 526 | Name string `yaml:"name"` | |
| 215 | 527 | Description string `yaml:"description"` | |
| @@ -254,17 +566,70 @@ type rawWorkflow struct { | |||
| 254 | 566 | Name string `yaml:"name"` | |
| 255 | 567 | Description string `yaml:"description"` | |
| 256 | 568 | On struct { | |
| 257 | Push struct { | ||
| 258 | // Pointers, so "absent" and "empty" stay different: a workflow with tags | ||
| 259 | // and no branches runs only on tags, while one with neither runs on every | ||
| 260 | // branch. | ||
| 261 | Branches *[]string `yaml:"branches"` | ||
| 262 | Tags *[]string `yaml:"tags"` | ||
| 263 | } `yaml:"push"` | ||
| 569 | Push rawPush `yaml:"push"` | ||
| 570 | ChangeRequest yaml.Node `yaml:"change-request"` | ||
| 571 | Issue yaml.Node `yaml:"issue"` | ||
| 572 | Comment yaml.Node `yaml:"comment"` | ||
| 573 | Schedule []rawSchedule `yaml:"schedule"` | ||
| 574 | WorkflowDispatch yaml.Node `yaml:"workflow-dispatch"` | ||
| 264 | 575 | } `yaml:"on"` | |
| 265 | 576 | Jobs map[string]rawJob `yaml:"jobs"` | |
| 266 | 577 | } | |
| 267 | 578 | ||
| 579 | // rawPush records that the push key was present. A workflow with only event | ||
| 580 | // triggers must not acquire the ordinary every-branch push default merely because | ||
| 581 | // it said nothing about branches. | ||
| 582 | type rawPush struct { | ||
| 583 | Set bool | ||
| 584 | Branches *[]string | ||
| 585 | Tags *[]string | ||
| 586 | Paths *[]string | ||
| 587 | PathsIgnore *[]string | ||
| 588 | } | ||
| 589 | |||
| 590 | func (p *rawPush) UnmarshalYAML(node *yaml.Node) error { | ||
| 591 | p.Set = true | ||
| 592 | if node.Tag == "!!null" { | ||
| 593 | return nil | ||
| 594 | } | ||
| 595 | if node.Kind != yaml.MappingNode { | ||
| 596 | return fmt.Errorf("push expects a mapping") | ||
| 597 | } | ||
| 598 | for i := 0; i < len(node.Content); i += 2 { | ||
| 599 | key := node.Content[i].Value | ||
| 600 | value := node.Content[i+1] | ||
| 601 | switch key { | ||
| 602 | case "branches": | ||
| 603 | var v []string | ||
| 604 | if err := value.Decode(&v); err != nil { | ||
| 605 | return err | ||
| 606 | } | ||
| 607 | p.Branches = &v | ||
| 608 | case "tags": | ||
| 609 | var v []string | ||
| 610 | if err := value.Decode(&v); err != nil { | ||
| 611 | return err | ||
| 612 | } | ||
| 613 | p.Tags = &v | ||
| 614 | case "paths": | ||
| 615 | var v []string | ||
| 616 | if err := value.Decode(&v); err != nil { | ||
| 617 | return err | ||
| 618 | } | ||
| 619 | p.Paths = &v | ||
| 620 | case "paths-ignore": | ||
| 621 | var v []string | ||
| 622 | if err := value.Decode(&v); err != nil { | ||
| 623 | return err | ||
| 624 | } | ||
| 625 | p.PathsIgnore = &v | ||
| 626 | default: | ||
| 627 | return fmt.Errorf("field %s is not a known key here", key) | ||
| 628 | } | ||
| 629 | } | ||
| 630 | return nil | ||
| 631 | } | ||
| 632 | |||
| 268 | 633 | // Parse validates one workflow file. Every problem is reported, not just the first. | |
| 269 | 634 | func Parse(file string, content string) (*Workflow, []string) { | |
| 270 | 635 | var problems []string | |
| @@ -310,29 +675,89 @@ func Parse(file string, content string) (*Workflow, []string) { | |||
| 310 | 675 | } else { | |
| 311 | 676 | wf.Description = desc | |
| 312 | 677 | } | |
| 313 | switch { | ||
| 314 | case rw.On.Push.Branches != nil: | ||
| 315 | wf.Branches = *rw.On.Push.Branches | ||
| 316 | case rw.On.Push.Tags == nil: | ||
| 317 | // Nothing was said, so this workflow is an ordinary push ci. Saying | ||
| 318 | // "tags:" is saying what it triggers on, so it does not also mean branches. | ||
| 319 | wf.Branches = []string{"*"} | ||
| 320 | } | ||
| 321 | for _, b := range wf.Branches { | ||
| 322 | if strings.TrimSpace(b) == "" || !branchGlob.MatchString(b) || strings.Contains(b, "..") { | ||
| 323 | bad("on.push.branches: %q is not a branch pattern", b) | ||
| 324 | } | ||
| 325 | } | ||
| 326 | 678 | // Tags have no default: a workflow that runs on a tag push has to say so, or a | |
| 327 | 679 | // release build would fire on every push. | |
| 328 | 680 | if rw.On.Push.Tags != nil { | |
| 329 | 681 | for _, tg := range *rw.On.Push.Tags { | |
| 330 | if strings.TrimSpace(tg) == "" || !branchGlob.MatchString(tg) || strings.Contains(tg, "..") { | ||
| 682 | pattern := strings.TrimPrefix(strings.TrimSpace(tg), "!") | ||
| 683 | if pattern == "" || !branchGlob.MatchString(tg) || strings.Contains(tg, "..") { | ||
| 331 | 684 | bad("on.push.tags: %q is not a tag pattern", tg) | |
| 332 | 685 | } | |
| 333 | 686 | wf.Tags = append(wf.Tags, tg) | |
| 334 | 687 | } | |
| 335 | 688 | } | |
| 689 | if rw.On.Push.Paths != nil { | ||
| 690 | wf.Paths = validPaths(*rw.On.Push.Paths, "on.push.paths", bad) | ||
| 691 | } | ||
| 692 | if rw.On.Push.PathsIgnore != nil { | ||
| 693 | wf.PathsIgnore = validPaths(*rw.On.Push.PathsIgnore, "on.push.paths-ignore", bad) | ||
| 694 | } | ||
| 695 | if trigger, problem := parseEventNode(&rw.On.ChangeRequest, "on.change-request", | ||
| 696 | changeRequestActions, defaultChangeRequestActions); problem != "" { | ||
| 697 | bad("%s", problem) | ||
| 698 | } else { | ||
| 699 | wf.ChangeRequest = trigger | ||
| 700 | } | ||
| 701 | if trigger, problem := parseEventNode(&rw.On.Issue, "on.issue", | ||
| 702 | issueActions, defaultIssueActions); problem != "" { | ||
| 703 | bad("%s", problem) | ||
| 704 | } else { | ||
| 705 | wf.Issues = trigger | ||
| 706 | } | ||
| 707 | if trigger, problem := parseEventNode(&rw.On.Comment, "on.comment", | ||
| 708 | commentActions, defaultCommentActions); problem != "" { | ||
| 709 | bad("%s", problem) | ||
| 710 | } else { | ||
| 711 | wf.Comments = trigger | ||
| 712 | } | ||
| 713 | if len(rw.On.Schedule) > MaxSchedules { | ||
| 714 | bad("on.schedule: %d entries, limit %d", len(rw.On.Schedule), MaxSchedules) | ||
| 715 | } | ||
| 716 | for i, rs := range rw.On.Schedule { | ||
| 717 | cron := strings.TrimSpace(rs.Cron) | ||
| 718 | if cron == "" { | ||
| 719 | bad("on.schedule[%d]: cron is required", i) | ||
| 720 | continue | ||
| 721 | } | ||
| 722 | if _, err := ParseCron(cron); err != nil { | ||
| 723 | bad("on.schedule[%d].cron: %v", i, err) | ||
| 724 | continue | ||
| 725 | } | ||
| 726 | wf.Schedules = append(wf.Schedules, Schedule{Cron: cron}) | ||
| 727 | } | ||
| 728 | if rw.On.WorkflowDispatch.Kind != 0 { | ||
| 729 | var raw rawDispatch | ||
| 730 | if err := raw.UnmarshalYAML(&rw.On.WorkflowDispatch); err != nil { | ||
| 731 | bad("on.workflow-dispatch: %v", err) | ||
| 732 | } else if dispatch, problems := parseDispatch(raw); len(problems) > 0 { | ||
| 733 | for _, problem := range problems { | ||
| 734 | bad("%s", problem) | ||
| 735 | } | ||
| 736 | } else { | ||
| 737 | wf.Dispatch = dispatch | ||
| 738 | } | ||
| 739 | } | ||
| 740 | |||
| 741 | switch { | ||
| 742 | case rw.On.Push.Branches != nil: | ||
| 743 | wf.Branches = *rw.On.Push.Branches | ||
| 744 | case rw.On.Push.Tags != nil: | ||
| 745 | // Tags have no branch default: a workflow that runs on a tag says so. | ||
| 746 | case rw.On.Push.Set: | ||
| 747 | // An empty push trigger means every branch, as it always has. | ||
| 748 | wf.Branches = []string{"*"} | ||
| 749 | case wf.ChangeRequest == nil && wf.Issues == nil && wf.Comments == nil && | ||
| 750 | len(wf.Schedules) == 0 && wf.Dispatch == nil: | ||
| 751 | // No trigger at all was declared, so this is an ordinary push ci. | ||
| 752 | wf.Branches = []string{"*"} | ||
| 753 | } | ||
| 754 | |||
| 755 | for _, b := range wf.Branches { | ||
| 756 | pattern := strings.TrimPrefix(strings.TrimSpace(b), "!") | ||
| 757 | if pattern == "" || !branchGlob.MatchString(b) || strings.Contains(b, "..") { | ||
| 758 | bad("on.push.branches: %q is not a branch pattern", b) | ||
| 759 | } | ||
| 760 | } | ||
| 336 | 761 | ||
| 337 | 762 | if len(rw.Jobs) == 0 { | |
| 338 | 763 | bad("at least one job is required") | |
| @@ -628,6 +1053,341 @@ func CheckWithImages(files, images map[string]string) ([]*Workflow, []string) { | |||
| 628 | 1053 | return out, problems | |
| 629 | 1054 | } | |
| 630 | 1055 | ||
| 1056 | // validPaths trims and validates a path-filter list. Path filters cannot climb out | ||
| 1057 | // of the tree, and they cannot be empty: a pattern that matches nothing would leave | ||
| 1058 | // a workflow permanently silent. | ||
| 1059 | func validPaths(list []string, where string, bad func(string, ...any)) []string { | ||
| 1060 | if len(list) > MaxPaths { | ||
| 1061 | bad("%s: %d patterns, limit %d", where, len(list), MaxPaths) | ||
| 1062 | } | ||
| 1063 | out := make([]string, 0, len(list)) | ||
| 1064 | for _, raw := range list { | ||
| 1065 | p := strings.TrimSpace(raw) | ||
| 1066 | switch { | ||
| 1067 | case p == "": | ||
| 1068 | bad("%s: an empty pattern matches no path", where) | ||
| 1069 | continue | ||
| 1070 | case strings.HasPrefix(p, "/"): | ||
| 1071 | bad("%s: %q must be relative to the repository root", where, p) | ||
| 1072 | continue | ||
| 1073 | case strings.Contains(p, ".."): | ||
| 1074 | bad("%s: %q must not contain ..", where, p) | ||
| 1075 | continue | ||
| 1076 | case !pathGlob.MatchString(p): | ||
| 1077 | bad("%s: %q is not a path pattern", where, p) | ||
| 1078 | continue | ||
| 1079 | } | ||
| 1080 | out = append(out, p) | ||
| 1081 | } | ||
| 1082 | return out | ||
| 1083 | } | ||
| 1084 | |||
| 1085 | // parseEventNode handles an event key written as an empty value (`issue:`) or as a | ||
| 1086 | // mapping. The outer decoder skips a null into a struct field, so the key is read | ||
| 1087 | // as a yaml.Node and absence (Kind 0) stays different from "present and empty". | ||
| 1088 | func parseEventNode(node *yaml.Node, where string, allowed, defaults []string) (*EventTrigger, string) { | ||
| 1089 | if node.Kind == 0 { | ||
| 1090 | return nil, "" | ||
| 1091 | } | ||
| 1092 | var raw rawEventTrigger | ||
| 1093 | if err := raw.UnmarshalYAML(node); err != nil { | ||
| 1094 | return nil, fmt.Sprintf("%s: %v", where, err) | ||
| 1095 | } | ||
| 1096 | return parseEventTrigger(raw, where, allowed, defaults) | ||
| 1097 | } | ||
| 1098 | |||
| 1099 | func parseEventTrigger(raw rawEventTrigger, where string, allowed, defaults []string) (*EventTrigger, string) { | ||
| 1100 | if !raw.Set { | ||
| 1101 | return nil, "" | ||
| 1102 | } | ||
| 1103 | types := raw.Types | ||
| 1104 | if len(types) == 0 { | ||
| 1105 | types = defaults | ||
| 1106 | } | ||
| 1107 | seen := map[string]bool{} | ||
| 1108 | out := make([]string, 0, len(types)) | ||
| 1109 | for _, value := range types { | ||
| 1110 | action := strings.TrimSpace(value) | ||
| 1111 | if !containsString(allowed, action) { | ||
| 1112 | return nil, fmt.Sprintf("%s.types: %q is not an action (%s)", where, value, strings.Join(allowed, ", ")) | ||
| 1113 | } | ||
| 1114 | if seen[action] { | ||
| 1115 | continue | ||
| 1116 | } | ||
| 1117 | seen[action] = true | ||
| 1118 | out = append(out, action) | ||
| 1119 | } | ||
| 1120 | return &EventTrigger{Types: out}, "" | ||
| 1121 | } | ||
| 1122 | |||
| 1123 | func parseDispatch(raw rawDispatch) (*Dispatch, []string) { | ||
| 1124 | var problems []string | ||
| 1125 | dispatch := &Dispatch{Inputs: map[string]DispatchInput{}} | ||
| 1126 | if len(raw.Inputs) > MaxDispatchInputs { | ||
| 1127 | problems = append(problems, fmt.Sprintf("on.workflow-dispatch.inputs: %d inputs, limit %d", | ||
| 1128 | len(raw.Inputs), MaxDispatchInputs)) | ||
| 1129 | } | ||
| 1130 | names := make([]string, 0, len(raw.Inputs)) | ||
| 1131 | for name := range raw.Inputs { | ||
| 1132 | names = append(names, name) | ||
| 1133 | } | ||
| 1134 | sort.Strings(names) | ||
| 1135 | for _, name := range names { | ||
| 1136 | where := "on.workflow-dispatch.inputs." + name | ||
| 1137 | rawInput := raw.Inputs[name] | ||
| 1138 | if !dispatchInputRE.MatchString(name) { | ||
| 1139 | problems = append(problems, fmt.Sprintf("%s: names are 1-32 characters, letters, digits, _ and -, starting with a letter or _", where)) | ||
| 1140 | continue | ||
| 1141 | } | ||
| 1142 | inputType := strings.TrimSpace(rawInput.Type) | ||
| 1143 | if inputType == "" { | ||
| 1144 | inputType = "string" | ||
| 1145 | } | ||
| 1146 | switch inputType { | ||
| 1147 | case "string", "boolean", "number", "choice": | ||
| 1148 | default: | ||
| 1149 | problems = append(problems, fmt.Sprintf("%s.type: %q is not a type (string, boolean, number, choice)", where, inputType)) | ||
| 1150 | continue | ||
| 1151 | } | ||
| 1152 | description := strings.TrimSpace(rawInput.Description) | ||
| 1153 | if len(description) > MaxDescriptionBytes { | ||
| 1154 | problems = append(problems, fmt.Sprintf("%s.description: %d bytes, limit %d", where, len(description), MaxDescriptionBytes)) | ||
| 1155 | } | ||
| 1156 | if strings.ContainsFunc(description, func(r rune) bool { | ||
| 1157 | return r < 0x20 || r == 0x7f || r == '\u2028' || r == '\u2029' | ||
| 1158 | }) { | ||
| 1159 | problems = append(problems, fmt.Sprintf("%s.description: must be one line of text", where)) | ||
| 1160 | } | ||
| 1161 | defaultValue := strings.TrimSpace(rawInput.Default) | ||
| 1162 | if rawInput.DefaultSet && len(defaultValue) > MaxDispatchValueBytes { | ||
| 1163 | problems = append(problems, fmt.Sprintf("%s.default: %d bytes, limit %d", where, len(defaultValue), MaxDispatchValueBytes)) | ||
| 1164 | } | ||
| 1165 | options := make([]string, 0, len(rawInput.Options)) | ||
| 1166 | seenOptions := map[string]bool{} | ||
| 1167 | for _, option := range rawInput.Options { | ||
| 1168 | option = strings.TrimSpace(option) | ||
| 1169 | if option == "" { | ||
| 1170 | problems = append(problems, fmt.Sprintf("%s.options: an empty choice is not a value", where)) | ||
| 1171 | continue | ||
| 1172 | } | ||
| 1173 | if seenOptions[option] { | ||
| 1174 | continue | ||
| 1175 | } | ||
| 1176 | seenOptions[option] = true | ||
| 1177 | options = append(options, option) | ||
| 1178 | } | ||
| 1179 | if inputType == "choice" { | ||
| 1180 | if len(options) == 0 { | ||
| 1181 | problems = append(problems, fmt.Sprintf("%s.options: a choice needs at least one option", where)) | ||
| 1182 | } | ||
| 1183 | if rawInput.DefaultSet && !seenOptions[defaultValue] { | ||
| 1184 | problems = append(problems, fmt.Sprintf("%s.default: %q is not one of the options", where, defaultValue)) | ||
| 1185 | } | ||
| 1186 | } else if len(options) > 0 { | ||
| 1187 | problems = append(problems, fmt.Sprintf("%s.options: only a choice input takes options", where)) | ||
| 1188 | } | ||
| 1189 | if rawInput.DefaultSet { | ||
| 1190 | if problem := validDispatchValue(inputType, defaultValue, options); problem != "" { | ||
| 1191 | problems = append(problems, fmt.Sprintf("%s.default: %s", where, problem)) | ||
| 1192 | } | ||
| 1193 | } | ||
| 1194 | dispatch.Inputs[name] = DispatchInput{ | ||
| 1195 | Description: description, | ||
| 1196 | Type: inputType, | ||
| 1197 | Required: rawInput.Required, | ||
| 1198 | Default: defaultValue, | ||
| 1199 | DefaultSet: rawInput.DefaultSet, | ||
| 1200 | Options: options, | ||
| 1201 | } | ||
| 1202 | } | ||
| 1203 | if len(problems) > 0 { | ||
| 1204 | return nil, problems | ||
| 1205 | } | ||
| 1206 | return dispatch, nil | ||
| 1207 | } | ||
| 1208 | |||
| 1209 | // ResolveDispatchInputs applies the declared types, defaults and required flags to | ||
| 1210 | // a manual run's values. It returns the values a step should see; an optional input | ||
| 1211 | // with no value is absent rather than an empty string, so a step can tell "not set" | ||
| 1212 | // from "set to empty". | ||
| 1213 | func (w *Workflow) ResolveDispatchInputs(given map[string]string) (map[string]string, []string) { | ||
| 1214 | if w.Dispatch == nil { | ||
| 1215 | return nil, []string{"this workflow has no workflow-dispatch trigger"} | ||
| 1216 | } | ||
| 1217 | var problems []string | ||
| 1218 | for name := range given { | ||
| 1219 | if _, ok := w.Dispatch.Inputs[name]; !ok { | ||
| 1220 | problems = append(problems, fmt.Sprintf("%q is not a declared input", name)) | ||
| 1221 | } | ||
| 1222 | } | ||
| 1223 | names := make([]string, 0, len(w.Dispatch.Inputs)) | ||
| 1224 | for name := range w.Dispatch.Inputs { | ||
| 1225 | names = append(names, name) | ||
| 1226 | } | ||
| 1227 | sort.Strings(names) | ||
| 1228 | values := map[string]string{} | ||
| 1229 | for _, name := range names { | ||
| 1230 | decl := w.Dispatch.Inputs[name] | ||
| 1231 | value, givenValue := given[name] | ||
| 1232 | if !givenValue { | ||
| 1233 | if !decl.DefaultSet { | ||
| 1234 | if decl.Required { | ||
| 1235 | problems = append(problems, fmt.Sprintf("%s is required", name)) | ||
| 1236 | } | ||
| 1237 | continue | ||
| 1238 | } | ||
| 1239 | value = decl.Default | ||
| 1240 | } | ||
| 1241 | if len(value) > MaxDispatchValueBytes { | ||
| 1242 | problems = append(problems, fmt.Sprintf("%s: %d bytes, limit %d", name, len(value), MaxDispatchValueBytes)) | ||
| 1243 | continue | ||
| 1244 | } | ||
| 1245 | if strings.ContainsAny(value, "\r\n\x00") { | ||
| 1246 | problems = append(problems, fmt.Sprintf("%s: must be one line", name)) | ||
| 1247 | continue | ||
| 1248 | } | ||
| 1249 | if problem := validDispatchValue(decl.Type, value, decl.Options); problem != "" { | ||
| 1250 | problems = append(problems, fmt.Sprintf("%s: %s", name, problem)) | ||
| 1251 | continue | ||
| 1252 | } | ||
| 1253 | values[name] = value | ||
| 1254 | } | ||
| 1255 | if len(problems) > 0 { | ||
| 1256 | return nil, problems | ||
| 1257 | } | ||
| 1258 | return values, nil | ||
| 1259 | } | ||
| 1260 | |||
| 1261 | func validDispatchValue(kind, value string, options []string) string { | ||
| 1262 | switch kind { | ||
| 1263 | case "boolean": | ||
| 1264 | if _, err := strconv.ParseBool(value); err != nil { | ||
| 1265 | return fmt.Sprintf("%q is not true or false", value) | ||
| 1266 | } | ||
| 1267 | case "number": | ||
| 1268 | if _, err := strconv.ParseFloat(value, 64); err != nil { | ||
| 1269 | return fmt.Sprintf("%q is not a number", value) | ||
| 1270 | } | ||
| 1271 | case "choice": | ||
| 1272 | if !containsString(options, value) { | ||
| 1273 | return fmt.Sprintf("%q is not one of %s", value, strings.Join(options, ", ")) | ||
| 1274 | } | ||
| 1275 | } | ||
| 1276 | return "" | ||
| 1277 | } | ||
| 1278 | |||
| 1279 | // Cron is one parsed five-field schedule. The fields are minute, hour, day of | ||
| 1280 | // month, month, and day of week, in UTC. It supports *, lists, ranges, and a step | ||
| 1281 | // on * or a range. Names and @shorthands are not part of the format. | ||
| 1282 | type Cron struct { | ||
| 1283 | minute, hour, dayOfMonth, month, dayOfWeek uint64 | ||
| 1284 | dayOfMonthAny, dayOfWeekAny bool | ||
| 1285 | } | ||
| 1286 | |||
| 1287 | // ParseCron parses one schedule expression. | ||
| 1288 | func ParseCron(spec string) (*Cron, error) { | ||
| 1289 | fields := strings.Fields(spec) | ||
| 1290 | if len(fields) != 5 { | ||
| 1291 | return nil, fmt.Errorf("%q needs 5 fields (minute hour day month weekday)", spec) | ||
| 1292 | } | ||
| 1293 | c := &Cron{} | ||
| 1294 | var err error | ||
| 1295 | if c.minute, _, err = parseCronField(fields[0], 0, 59, false); err != nil { | ||
| 1296 | return nil, fmt.Errorf("minute: %w", err) | ||
| 1297 | } | ||
| 1298 | if c.hour, _, err = parseCronField(fields[1], 0, 23, false); err != nil { | ||
| 1299 | return nil, fmt.Errorf("hour: %w", err) | ||
| 1300 | } | ||
| 1301 | if c.dayOfMonth, c.dayOfMonthAny, err = parseCronField(fields[2], 1, 31, false); err != nil { | ||
| 1302 | return nil, fmt.Errorf("day of month: %w", err) | ||
| 1303 | } | ||
| 1304 | if c.month, _, err = parseCronField(fields[3], 1, 12, false); err != nil { | ||
| 1305 | return nil, fmt.Errorf("month: %w", err) | ||
| 1306 | } | ||
| 1307 | if c.dayOfWeek, c.dayOfWeekAny, err = parseCronField(fields[4], 0, 7, true); err != nil { | ||
| 1308 | return nil, fmt.Errorf("day of week: %w", err) | ||
| 1309 | } | ||
| 1310 | // Sunday is both 0 and 7. Normalising here means a schedule that says 7 and | ||
| 1311 | // one that says 0 cannot disagree about the same day. | ||
| 1312 | if c.dayOfWeek&(1<<7) != 0 { | ||
| 1313 | c.dayOfWeek |= 1 | ||
| 1314 | c.dayOfWeek &^= 1 << 7 | ||
| 1315 | } | ||
| 1316 | return c, nil | ||
| 1317 | } | ||
| 1318 | |||
| 1319 | // Matches reports whether t falls in this schedule's minute, hour, and date. | ||
| 1320 | func (c *Cron) Matches(t time.Time) bool { | ||
| 1321 | if c.minute&(1<<uint(t.Minute())) == 0 || c.hour&(1<<uint(t.Hour())) == 0 || | ||
| 1322 | c.month&(1<<uint(int(t.Month()))) == 0 { | ||
| 1323 | return false | ||
| 1324 | } | ||
| 1325 | dayOfMonth := c.dayOfMonth&(1<<uint(t.Day())) != 0 | ||
| 1326 | dayOfWeek := c.dayOfWeek&(1<<uint(int(t.Weekday()))) != 0 | ||
| 1327 | // Cron's odd rule: when both date fields are restricted, either one may match. | ||
| 1328 | if c.dayOfMonthAny || c.dayOfWeekAny { | ||
| 1329 | return dayOfMonth && dayOfWeek | ||
| 1330 | } | ||
| 1331 | return dayOfMonth || dayOfWeek | ||
| 1332 | } | ||
| 1333 | |||
| 1334 | func parseCronField(field string, min, max int, allowSeven bool) (uint64, bool, error) { | ||
| 1335 | var mask uint64 | ||
| 1336 | any := false | ||
| 1337 | for _, part := range strings.Split(field, ",") { | ||
| 1338 | part = strings.TrimSpace(part) | ||
| 1339 | if part == "" { | ||
| 1340 | return 0, false, fmt.Errorf("empty list entry") | ||
| 1341 | } | ||
| 1342 | original := part | ||
| 1343 | step := 1 | ||
| 1344 | if i := strings.IndexByte(part, '/'); i >= 0 { | ||
| 1345 | n, err := strconv.Atoi(part[i+1:]) | ||
| 1346 | if err != nil || n <= 0 { | ||
| 1347 | return 0, false, fmt.Errorf("%q has an invalid step", original) | ||
| 1348 | } | ||
| 1349 | step = n | ||
| 1350 | part = part[:i] | ||
| 1351 | } | ||
| 1352 | start, end := min, max | ||
| 1353 | switch { | ||
| 1354 | case part == "*": | ||
| 1355 | if step == 1 { | ||
| 1356 | any = true | ||
| 1357 | } | ||
| 1358 | case strings.Contains(part, "-"): | ||
| 1359 | bounds := strings.SplitN(part, "-", 2) | ||
| 1360 | var err error | ||
| 1361 | if start, err = strconv.Atoi(bounds[0]); err != nil { | ||
| 1362 | return 0, false, fmt.Errorf("%q is not a range", original) | ||
| 1363 | } | ||
| 1364 | if end, err = strconv.Atoi(bounds[1]); err != nil { | ||
| 1365 | return 0, false, fmt.Errorf("%q is not a range", original) | ||
| 1366 | } | ||
| 1367 | default: | ||
| 1368 | if step != 1 { | ||
| 1369 | return 0, false, fmt.Errorf("%q needs a range or * before /%d", original, step) | ||
| 1370 | } | ||
| 1371 | n, err := strconv.Atoi(part) | ||
| 1372 | if err != nil { | ||
| 1373 | return 0, false, fmt.Errorf("%q is not a number", original) | ||
| 1374 | } | ||
| 1375 | start, end = n, n | ||
| 1376 | } | ||
| 1377 | if start < min || end > max || start > end { | ||
| 1378 | return 0, false, fmt.Errorf("%q is outside %d-%d", original, min, max) | ||
| 1379 | } | ||
| 1380 | for n := start; n <= end; n += step { | ||
| 1381 | if allowSeven && n == 7 { | ||
| 1382 | mask |= 1 << 7 | ||
| 1383 | continue | ||
| 1384 | } | ||
| 1385 | mask |= 1 << uint(n) | ||
| 1386 | } | ||
| 1387 | } | ||
| 1388 | return mask, any, nil | ||
| 1389 | } | ||
| 1390 | |||
| 631 | 1391 | // tidyYAMLError drops the Go type name yaml.v3 leaks into its messages, which | |
| 632 | 1392 | // means nothing to someone editing a ci. | |
| 633 | 1393 | func tidyYAMLError(err error) string { | |