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