github.com/nektos/act@v0.2.63-0.20240520024548-8acde99bfa9c/pkg/model/workflow.go (about) 1 package model 2 3 import ( 4 "fmt" 5 "io" 6 "reflect" 7 "regexp" 8 "strconv" 9 "strings" 10 11 "github.com/nektos/act/pkg/common" 12 log "github.com/sirupsen/logrus" 13 "gopkg.in/yaml.v3" 14 ) 15 16 // Workflow is the structure of the files in .github/workflows 17 type Workflow struct { 18 File string 19 Name string `yaml:"name"` 20 RawOn yaml.Node `yaml:"on"` 21 Env map[string]string `yaml:"env"` 22 Jobs map[string]*Job `yaml:"jobs"` 23 Defaults Defaults `yaml:"defaults"` 24 } 25 26 // On events for the workflow 27 func (w *Workflow) On() []string { 28 switch w.RawOn.Kind { 29 case yaml.ScalarNode: 30 var val string 31 err := w.RawOn.Decode(&val) 32 if err != nil { 33 log.Fatal(err) 34 } 35 return []string{val} 36 case yaml.SequenceNode: 37 var val []string 38 err := w.RawOn.Decode(&val) 39 if err != nil { 40 log.Fatal(err) 41 } 42 return val 43 case yaml.MappingNode: 44 var val map[string]interface{} 45 err := w.RawOn.Decode(&val) 46 if err != nil { 47 log.Fatal(err) 48 } 49 var keys []string 50 for k := range val { 51 keys = append(keys, k) 52 } 53 return keys 54 } 55 return nil 56 } 57 58 func (w *Workflow) OnEvent(event string) interface{} { 59 if w.RawOn.Kind == yaml.MappingNode { 60 var val map[string]interface{} 61 if !decodeNode(w.RawOn, &val) { 62 return nil 63 } 64 return val[event] 65 } 66 return nil 67 } 68 69 type WorkflowDispatchInput struct { 70 Description string `yaml:"description"` 71 Required bool `yaml:"required"` 72 Default string `yaml:"default"` 73 Type string `yaml:"type"` 74 Options []string `yaml:"options"` 75 } 76 77 type WorkflowDispatch struct { 78 Inputs map[string]WorkflowDispatchInput `yaml:"inputs"` 79 } 80 81 func (w *Workflow) WorkflowDispatchConfig() *WorkflowDispatch { 82 switch w.RawOn.Kind { 83 case yaml.ScalarNode: 84 var val string 85 if !decodeNode(w.RawOn, &val) { 86 return nil 87 } 88 if val == "workflow_dispatch" { 89 return &WorkflowDispatch{} 90 } 91 case yaml.SequenceNode: 92 var val []string 93 if !decodeNode(w.RawOn, &val) { 94 return nil 95 } 96 for _, v := range val { 97 if v == "workflow_dispatch" { 98 return &WorkflowDispatch{} 99 } 100 } 101 case yaml.MappingNode: 102 var val map[string]yaml.Node 103 if !decodeNode(w.RawOn, &val) { 104 return nil 105 } 106 107 n, found := val["workflow_dispatch"] 108 var workflowDispatch WorkflowDispatch 109 if found && decodeNode(n, &workflowDispatch) { 110 return &workflowDispatch 111 } 112 default: 113 return nil 114 } 115 return nil 116 } 117 118 type WorkflowCallInput struct { 119 Description string `yaml:"description"` 120 Required bool `yaml:"required"` 121 Default string `yaml:"default"` 122 Type string `yaml:"type"` 123 } 124 125 type WorkflowCallOutput struct { 126 Description string `yaml:"description"` 127 Value string `yaml:"value"` 128 } 129 130 type WorkflowCall struct { 131 Inputs map[string]WorkflowCallInput `yaml:"inputs"` 132 Outputs map[string]WorkflowCallOutput `yaml:"outputs"` 133 } 134 135 type WorkflowCallResult struct { 136 Outputs map[string]string 137 } 138 139 func (w *Workflow) WorkflowCallConfig() *WorkflowCall { 140 if w.RawOn.Kind != yaml.MappingNode { 141 // The callers expect for "on: workflow_call" and "on: [ workflow_call ]" a non nil return value 142 return &WorkflowCall{} 143 } 144 145 var val map[string]yaml.Node 146 if !decodeNode(w.RawOn, &val) { 147 return &WorkflowCall{} 148 } 149 150 var config WorkflowCall 151 node := val["workflow_call"] 152 if !decodeNode(node, &config) { 153 return &WorkflowCall{} 154 } 155 156 return &config 157 } 158 159 // Job is the structure of one job in a workflow 160 type Job struct { 161 Name string `yaml:"name"` 162 RawNeeds yaml.Node `yaml:"needs"` 163 RawRunsOn yaml.Node `yaml:"runs-on"` 164 Env yaml.Node `yaml:"env"` 165 If yaml.Node `yaml:"if"` 166 Steps []*Step `yaml:"steps"` 167 TimeoutMinutes string `yaml:"timeout-minutes"` 168 Services map[string]*ContainerSpec `yaml:"services"` 169 Strategy *Strategy `yaml:"strategy"` 170 RawContainer yaml.Node `yaml:"container"` 171 Defaults Defaults `yaml:"defaults"` 172 Outputs map[string]string `yaml:"outputs"` 173 Uses string `yaml:"uses"` 174 With map[string]interface{} `yaml:"with"` 175 RawSecrets yaml.Node `yaml:"secrets"` 176 Result string 177 } 178 179 // Strategy for the job 180 type Strategy struct { 181 FailFast bool 182 MaxParallel int 183 FailFastString string `yaml:"fail-fast"` 184 MaxParallelString string `yaml:"max-parallel"` 185 RawMatrix yaml.Node `yaml:"matrix"` 186 } 187 188 // Default settings that will apply to all steps in the job or workflow 189 type Defaults struct { 190 Run RunDefaults `yaml:"run"` 191 } 192 193 // Defaults for all run steps in the job or workflow 194 type RunDefaults struct { 195 Shell string `yaml:"shell"` 196 WorkingDirectory string `yaml:"working-directory"` 197 } 198 199 // GetMaxParallel sets default and returns value for `max-parallel` 200 func (s Strategy) GetMaxParallel() int { 201 // MaxParallel default value is `GitHub will maximize the number of jobs run in parallel depending on the available runners on GitHub-hosted virtual machines` 202 // So I take the liberty to hardcode default limit to 4 and this is because: 203 // 1: tl;dr: self-hosted does only 1 parallel job - https://github.com/actions/runner/issues/639#issuecomment-825212735 204 // 2: GH has 20 parallel job limit (for free tier) - https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/usage-limits-billing-and-administration.md?plain=1#L45 205 // 3: I want to add support for MaxParallel to act and 20! parallel jobs is a bit overkill IMHO 206 maxParallel := 4 207 if s.MaxParallelString != "" { 208 var err error 209 if maxParallel, err = strconv.Atoi(s.MaxParallelString); err != nil { 210 log.Errorf("Failed to parse 'max-parallel' option: %v", err) 211 } 212 } 213 return maxParallel 214 } 215 216 // GetFailFast sets default and returns value for `fail-fast` 217 func (s Strategy) GetFailFast() bool { 218 // FailFast option is true by default: https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/workflow-syntax-for-github-actions.md?plain=1#L1107 219 failFast := true 220 log.Debug(s.FailFastString) 221 if s.FailFastString != "" { 222 var err error 223 if failFast, err = strconv.ParseBool(s.FailFastString); err != nil { 224 log.Errorf("Failed to parse 'fail-fast' option: %v", err) 225 } 226 } 227 return failFast 228 } 229 230 func (j *Job) InheritSecrets() bool { 231 if j.RawSecrets.Kind != yaml.ScalarNode { 232 return false 233 } 234 235 var val string 236 if !decodeNode(j.RawSecrets, &val) { 237 return false 238 } 239 240 return val == "inherit" 241 } 242 243 func (j *Job) Secrets() map[string]string { 244 if j.RawSecrets.Kind != yaml.MappingNode { 245 return nil 246 } 247 248 var val map[string]string 249 if !decodeNode(j.RawSecrets, &val) { 250 return nil 251 } 252 253 return val 254 } 255 256 // Container details for the job 257 func (j *Job) Container() *ContainerSpec { 258 var val *ContainerSpec 259 switch j.RawContainer.Kind { 260 case yaml.ScalarNode: 261 val = new(ContainerSpec) 262 if !decodeNode(j.RawContainer, &val.Image) { 263 return nil 264 } 265 case yaml.MappingNode: 266 val = new(ContainerSpec) 267 if !decodeNode(j.RawContainer, val) { 268 return nil 269 } 270 } 271 return val 272 } 273 274 // Needs list for Job 275 func (j *Job) Needs() []string { 276 switch j.RawNeeds.Kind { 277 case yaml.ScalarNode: 278 var val string 279 if !decodeNode(j.RawNeeds, &val) { 280 return nil 281 } 282 return []string{val} 283 case yaml.SequenceNode: 284 var val []string 285 if !decodeNode(j.RawNeeds, &val) { 286 return nil 287 } 288 return val 289 } 290 return nil 291 } 292 293 // RunsOn list for Job 294 func (j *Job) RunsOn() []string { 295 switch j.RawRunsOn.Kind { 296 case yaml.MappingNode: 297 var val struct { 298 Group string 299 Labels yaml.Node 300 } 301 302 if !decodeNode(j.RawRunsOn, &val) { 303 return nil 304 } 305 306 labels := nodeAsStringSlice(val.Labels) 307 308 if val.Group != "" { 309 labels = append(labels, val.Group) 310 } 311 312 return labels 313 default: 314 return nodeAsStringSlice(j.RawRunsOn) 315 } 316 } 317 318 func nodeAsStringSlice(node yaml.Node) []string { 319 switch node.Kind { 320 case yaml.ScalarNode: 321 var val string 322 if !decodeNode(node, &val) { 323 return nil 324 } 325 return []string{val} 326 case yaml.SequenceNode: 327 var val []string 328 if !decodeNode(node, &val) { 329 return nil 330 } 331 return val 332 } 333 return nil 334 } 335 336 func environment(yml yaml.Node) map[string]string { 337 env := make(map[string]string) 338 if yml.Kind == yaml.MappingNode { 339 if !decodeNode(yml, &env) { 340 return nil 341 } 342 } 343 return env 344 } 345 346 // Environment returns string-based key=value map for a job 347 func (j *Job) Environment() map[string]string { 348 return environment(j.Env) 349 } 350 351 // Matrix decodes RawMatrix YAML node 352 func (j *Job) Matrix() map[string][]interface{} { 353 if j.Strategy.RawMatrix.Kind == yaml.MappingNode { 354 var val map[string][]interface{} 355 if !decodeNode(j.Strategy.RawMatrix, &val) { 356 return nil 357 } 358 return val 359 } 360 return nil 361 } 362 363 // GetMatrixes returns the matrix cross product 364 // It skips includes and hard fails excludes for non-existing keys 365 // 366 //nolint:gocyclo 367 func (j *Job) GetMatrixes() ([]map[string]interface{}, error) { 368 matrixes := make([]map[string]interface{}, 0) 369 if j.Strategy != nil { 370 j.Strategy.FailFast = j.Strategy.GetFailFast() 371 j.Strategy.MaxParallel = j.Strategy.GetMaxParallel() 372 373 if m := j.Matrix(); m != nil { 374 includes := make([]map[string]interface{}, 0) 375 extraIncludes := make([]map[string]interface{}, 0) 376 for _, v := range m["include"] { 377 switch t := v.(type) { 378 case []interface{}: 379 for _, i := range t { 380 i := i.(map[string]interface{}) 381 extraInclude := true 382 for k := range i { 383 if _, ok := m[k]; ok { 384 includes = append(includes, i) 385 extraInclude = false 386 break 387 } 388 } 389 if extraInclude { 390 extraIncludes = append(extraIncludes, i) 391 } 392 } 393 case interface{}: 394 v := v.(map[string]interface{}) 395 extraInclude := true 396 for k := range v { 397 if _, ok := m[k]; ok { 398 includes = append(includes, v) 399 extraInclude = false 400 break 401 } 402 } 403 if extraInclude { 404 extraIncludes = append(extraIncludes, v) 405 } 406 } 407 } 408 delete(m, "include") 409 410 excludes := make([]map[string]interface{}, 0) 411 for _, e := range m["exclude"] { 412 e := e.(map[string]interface{}) 413 for k := range e { 414 if _, ok := m[k]; ok { 415 excludes = append(excludes, e) 416 } else { 417 // We fail completely here because that's what GitHub does for non-existing matrix keys, fail on exclude, silent skip on include 418 return nil, fmt.Errorf("the workflow is not valid. Matrix exclude key %q does not match any key within the matrix", k) 419 } 420 } 421 } 422 delete(m, "exclude") 423 424 matrixProduct := common.CartesianProduct(m) 425 MATRIX: 426 for _, matrix := range matrixProduct { 427 for _, exclude := range excludes { 428 if commonKeysMatch(matrix, exclude) { 429 log.Debugf("Skipping matrix '%v' due to exclude '%v'", matrix, exclude) 430 continue MATRIX 431 } 432 } 433 matrixes = append(matrixes, matrix) 434 } 435 for _, include := range includes { 436 matched := false 437 for _, matrix := range matrixes { 438 if commonKeysMatch2(matrix, include, m) { 439 matched = true 440 log.Debugf("Adding include values '%v' to existing entry", include) 441 for k, v := range include { 442 matrix[k] = v 443 } 444 } 445 } 446 if !matched { 447 extraIncludes = append(extraIncludes, include) 448 } 449 } 450 for _, include := range extraIncludes { 451 log.Debugf("Adding include '%v'", include) 452 matrixes = append(matrixes, include) 453 } 454 if len(matrixes) == 0 { 455 matrixes = append(matrixes, make(map[string]interface{})) 456 } 457 } else { 458 matrixes = append(matrixes, make(map[string]interface{})) 459 } 460 } else { 461 matrixes = append(matrixes, make(map[string]interface{})) 462 log.Debugf("Empty Strategy, matrixes=%v", matrixes) 463 } 464 return matrixes, nil 465 } 466 467 func commonKeysMatch(a map[string]interface{}, b map[string]interface{}) bool { 468 for aKey, aVal := range a { 469 if bVal, ok := b[aKey]; ok && !reflect.DeepEqual(aVal, bVal) { 470 return false 471 } 472 } 473 return true 474 } 475 476 func commonKeysMatch2(a map[string]interface{}, b map[string]interface{}, m map[string][]interface{}) bool { 477 for aKey, aVal := range a { 478 _, useKey := m[aKey] 479 if bVal, ok := b[aKey]; useKey && ok && !reflect.DeepEqual(aVal, bVal) { 480 return false 481 } 482 } 483 return true 484 } 485 486 // JobType describes what type of job we are about to run 487 type JobType int 488 489 const ( 490 // JobTypeDefault is all jobs that have a `run` attribute 491 JobTypeDefault JobType = iota 492 493 // JobTypeReusableWorkflowLocal is all jobs that have a `uses` that is a local workflow in the .github/workflows directory 494 JobTypeReusableWorkflowLocal 495 496 // JobTypeReusableWorkflowRemote is all jobs that have a `uses` that references a workflow file in a github repo 497 JobTypeReusableWorkflowRemote 498 499 // JobTypeInvalid represents a job which is not configured correctly 500 JobTypeInvalid 501 ) 502 503 func (j JobType) String() string { 504 switch j { 505 case JobTypeDefault: 506 return "default" 507 case JobTypeReusableWorkflowLocal: 508 return "local-reusable-workflow" 509 case JobTypeReusableWorkflowRemote: 510 return "remote-reusable-workflow" 511 } 512 return "unknown" 513 } 514 515 // Type returns the type of the job 516 func (j *Job) Type() (JobType, error) { 517 isReusable := j.Uses != "" 518 519 if isReusable { 520 isYaml, _ := regexp.MatchString(`\.(ya?ml)(?:$|@)`, j.Uses) 521 522 if isYaml { 523 isLocalPath := strings.HasPrefix(j.Uses, "./") 524 isRemotePath, _ := regexp.MatchString(`^[^.](.+?/){2,}.+\.ya?ml@`, j.Uses) 525 hasVersion, _ := regexp.MatchString(`\.ya?ml@`, j.Uses) 526 527 if isLocalPath { 528 return JobTypeReusableWorkflowLocal, nil 529 } else if isRemotePath && hasVersion { 530 return JobTypeReusableWorkflowRemote, nil 531 } 532 } 533 534 return JobTypeInvalid, fmt.Errorf("`uses` key references invalid workflow path '%s'. Must start with './' if it's a local workflow, or must start with '<org>/<repo>/' and include an '@' if it's a remote workflow", j.Uses) 535 } 536 537 return JobTypeDefault, nil 538 } 539 540 // ContainerSpec is the specification of the container to use for the job 541 type ContainerSpec struct { 542 Image string `yaml:"image"` 543 Env map[string]string `yaml:"env"` 544 Ports []string `yaml:"ports"` 545 Volumes []string `yaml:"volumes"` 546 Options string `yaml:"options"` 547 Credentials map[string]string `yaml:"credentials"` 548 Entrypoint string 549 Args string 550 Name string 551 Reuse bool 552 } 553 554 // Step is the structure of one step in a job 555 type Step struct { 556 ID string `yaml:"id"` 557 If yaml.Node `yaml:"if"` 558 Name string `yaml:"name"` 559 Uses string `yaml:"uses"` 560 Run string `yaml:"run"` 561 WorkingDirectory string `yaml:"working-directory"` 562 Shell string `yaml:"shell"` 563 Env yaml.Node `yaml:"env"` 564 With map[string]string `yaml:"with"` 565 RawContinueOnError string `yaml:"continue-on-error"` 566 TimeoutMinutes string `yaml:"timeout-minutes"` 567 } 568 569 // String gets the name of step 570 func (s *Step) String() string { 571 if s.Name != "" { 572 return s.Name 573 } else if s.Uses != "" { 574 return s.Uses 575 } else if s.Run != "" { 576 return s.Run 577 } 578 return s.ID 579 } 580 581 // Environment returns string-based key=value map for a step 582 func (s *Step) Environment() map[string]string { 583 return environment(s.Env) 584 } 585 586 // GetEnv gets the env for a step 587 func (s *Step) GetEnv() map[string]string { 588 env := s.Environment() 589 590 for k, v := range s.With { 591 envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(k), "_") 592 envKey = fmt.Sprintf("INPUT_%s", strings.ToUpper(envKey)) 593 env[envKey] = v 594 } 595 return env 596 } 597 598 // ShellCommand returns the command for the shell 599 func (s *Step) ShellCommand() string { 600 shellCommand := "" 601 602 //Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L9-L17 603 switch s.Shell { 604 case "", "bash": 605 shellCommand = "bash --noprofile --norc -e -o pipefail {0}" 606 case "pwsh": 607 shellCommand = "pwsh -command . '{0}'" 608 case "python": 609 shellCommand = "python {0}" 610 case "sh": 611 shellCommand = "sh -e {0}" 612 case "cmd": 613 shellCommand = "cmd /D /E:ON /V:OFF /S /C \"CALL \"{0}\"\"" 614 case "powershell": 615 shellCommand = "powershell -command . '{0}'" 616 default: 617 shellCommand = s.Shell 618 } 619 return shellCommand 620 } 621 622 // StepType describes what type of step we are about to run 623 type StepType int 624 625 const ( 626 // StepTypeRun is all steps that have a `run` attribute 627 StepTypeRun StepType = iota 628 629 // StepTypeUsesDockerURL is all steps that have a `uses` that is of the form `docker://...` 630 StepTypeUsesDockerURL 631 632 // StepTypeUsesActionLocal is all steps that have a `uses` that is a local action in a subdirectory 633 StepTypeUsesActionLocal 634 635 // StepTypeUsesActionRemote is all steps that have a `uses` that is a reference to a github repo 636 StepTypeUsesActionRemote 637 638 // StepTypeReusableWorkflowLocal is all steps that have a `uses` that is a local workflow in the .github/workflows directory 639 StepTypeReusableWorkflowLocal 640 641 // StepTypeReusableWorkflowRemote is all steps that have a `uses` that references a workflow file in a github repo 642 StepTypeReusableWorkflowRemote 643 644 // StepTypeInvalid is for steps that have invalid step action 645 StepTypeInvalid 646 ) 647 648 func (s StepType) String() string { 649 switch s { 650 case StepTypeInvalid: 651 return "invalid" 652 case StepTypeRun: 653 return "run" 654 case StepTypeUsesActionLocal: 655 return "local-action" 656 case StepTypeUsesActionRemote: 657 return "remote-action" 658 case StepTypeUsesDockerURL: 659 return "docker" 660 case StepTypeReusableWorkflowLocal: 661 return "local-reusable-workflow" 662 case StepTypeReusableWorkflowRemote: 663 return "remote-reusable-workflow" 664 } 665 return "unknown" 666 } 667 668 // Type returns the type of the step 669 func (s *Step) Type() StepType { 670 if s.Run == "" && s.Uses == "" { 671 return StepTypeInvalid 672 } 673 674 if s.Run != "" { 675 if s.Uses != "" { 676 return StepTypeInvalid 677 } 678 return StepTypeRun 679 } else if strings.HasPrefix(s.Uses, "docker://") { 680 return StepTypeUsesDockerURL 681 } else if strings.HasPrefix(s.Uses, "./.github/workflows") && (strings.HasSuffix(s.Uses, ".yml") || strings.HasSuffix(s.Uses, ".yaml")) { 682 return StepTypeReusableWorkflowLocal 683 } else if !strings.HasPrefix(s.Uses, "./") && strings.Contains(s.Uses, ".github/workflows") && (strings.Contains(s.Uses, ".yml@") || strings.Contains(s.Uses, ".yaml@")) { 684 return StepTypeReusableWorkflowRemote 685 } else if strings.HasPrefix(s.Uses, "./") { 686 return StepTypeUsesActionLocal 687 } 688 return StepTypeUsesActionRemote 689 } 690 691 // ReadWorkflow returns a list of jobs for a given workflow file reader 692 func ReadWorkflow(in io.Reader) (*Workflow, error) { 693 w := new(Workflow) 694 err := yaml.NewDecoder(in).Decode(w) 695 return w, err 696 } 697 698 // GetJob will get a job by name in the workflow 699 func (w *Workflow) GetJob(jobID string) *Job { 700 for id, j := range w.Jobs { 701 if jobID == id { 702 if j.Name == "" { 703 j.Name = id 704 } 705 if j.If.Value == "" { 706 j.If.Value = "success()" 707 } 708 return j 709 } 710 } 711 return nil 712 } 713 714 // GetJobIDs will get all the job names in the workflow 715 func (w *Workflow) GetJobIDs() []string { 716 ids := make([]string, 0) 717 for id := range w.Jobs { 718 ids = append(ids, id) 719 } 720 return ids 721 } 722 723 var OnDecodeNodeError = func(node yaml.Node, out interface{}, err error) { 724 log.Fatalf("Failed to decode node %v into %T: %v", node, out, err) 725 } 726 727 func decodeNode(node yaml.Node, out interface{}) bool { 728 if err := node.Decode(out); err != nil { 729 if OnDecodeNodeError != nil { 730 OnDecodeNodeError(node, out, err) 731 } 732 return false 733 } 734 return true 735 }