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  }