diff --git a/.github/workflows/graphs/build-test-publish.act b/.github/workflows/graphs/build-test-publish.act index 3296bde..2b431ac 100644 --- a/.github/workflows/graphs/build-test-publish.act +++ b/.github/workflows/graphs/build-test-publish.act @@ -2172,12 +2172,10 @@ nodes: plum-kiwano-lobster: dev.actionforge.actrun coral-dragonfruit-jackfruit: '' persimmon-date-coral: distribution.xml - blackberry-apricot-butterfly: 'Developer ID Installer: Actionforge Inc. (D9L94G8QN4)' grape-peach-apricot: 1.0.0 goose-goat-indigo: '' guava-boysenberry-hippopotamus: '' strawberry-nectarine-seahorse: actrun-cli.pkg - pomegranate-cranberry-gold: actrun-cli.pkg graph: entry: group-inputs-v1-kiwano-peach-horse type: group diff --git a/cmd/cmd_validate.go b/cmd/cmd_validate.go index bde1bc2..4f19b55 100644 --- a/cmd/cmd_validate.go +++ b/cmd/cmd_validate.go @@ -116,7 +116,14 @@ func validateGraph(filePath string) error { hasErrors = true } - _, errs := core.LoadGraph(graphYaml, nil, "", true, core.RunOpts{}) + opts := core.RunOpts{ + VS: &core.ValidationState{}, + OverrideSecrets: make(map[string]string), + } + if ghToken := u.GetGhTokenFromEnv(); ghToken != "" { + opts.OverrideSecrets["GITHUB_TOKEN"] = ghToken + } + _, errs := core.LoadGraph(graphYaml, nil, "", opts) if len(errs) > 0 { fmt.Printf("\n❌ Graph validation failed with %d error(s):\n", len(errs)) @@ -132,6 +139,13 @@ func validateGraph(filePath string) error { hasErrors = true } + if len(opts.VS.Warnings) > 0 { + fmt.Printf("\n⚠️ %d warning(s):\n", len(opts.VS.Warnings)) + for i, w := range opts.VS.Warnings { + fmt.Printf(" %d. %s\n", i+1, w) + } + } + if hasErrors { return fmt.Errorf("validation failed") } diff --git a/core/base.go b/core/base.go index 6597d8c..e7ba1bc 100644 --- a/core/base.go +++ b/core/base.go @@ -180,12 +180,12 @@ type NodeBaseInterface interface { // Base component for nodes that offer values from other nodes. // The node that implements this component has outgoing connections. type NodeBaseComponent struct { - Name string // Human readable name of the node - Label string // Label of the node shown in the graph editor - Id string // Unique identifier for the node - FullPath string // Full path of the node within the graph hierarchy - CacheId string // Unique identifier for the cache - NodeType string // Node type of the node (e.g. core/run@v1 or github.com/actions/checkout@v3) + Name string // Human readable name of the node + Label string // Label of the node shown in the graph editor + Id string // Unique identifier for the node + FullPath string // Full path of the node within the graph hierarchy + CacheId string // Unique identifier for the cache + NodeType string // Node type of the node (e.g. core/run@v1 or github.com/actions/checkout@v3) Graph *ActionGraph Parent NodeBaseInterface isExecutionNode bool @@ -605,7 +605,13 @@ func NewGhActionNode(nodeType string, parent NodeBaseInterface, parentId string, node, errs := factoryEntry.FactoryFn(nodeType, parent, parentId, nil, validate, opts) if len(errs) > 0 { - return nil, errs + if node == nil { + return nil, errs + } + // Factory returned a valid node with warnings/errors (e.g. validation + // proxy). Initialise the node and pass the errors through. + utils.InitMapAndSliceInStructRecursively(reflect.ValueOf(node)) + return node, errs } utils.InitMapAndSliceInStructRecursively(reflect.ValueOf(node)) diff --git a/core/graph.go b/core/graph.go index 60b675f..40053c5 100644 --- a/core/graph.go +++ b/core/graph.go @@ -32,6 +32,7 @@ type RunOpts struct { OverrideEnv map[string]string Args []string LocalGhServer bool + VS *ValidationState } type ActionGraph struct { @@ -46,6 +47,14 @@ type ActionGraph struct { ConcurrencyLocks *sync.Map `yaml:"-" json:"-"` } +// ValidationState collects errors and warnings during graph validation. +// When a non-nil *ValidationState is passed to Load* functions, it signals +// validation mode: errors are accumulated instead of failing fast. +type ValidationState struct { + Errors []error + Warnings []string +} + func (ag *ActionGraph) AddNode(nodeId string, node NodeBaseInterface) { ag.Nodes[nodeId] = node } @@ -87,27 +96,29 @@ func NewActionGraph() ActionGraph { } } -// helper to handle error collection -func collectOrReturn(err error, validate bool, errList *[]error) error { +// collectOrReturn appends err to vs.Errors when in validation mode (vs != nil) +// and returns nil so the caller can continue. In non-validation mode it returns +// the error directly so the caller can fail fast. +func collectOrReturn(err error, vs *ValidationState) error { if err == nil { return nil } - if validate { - *errList = append(*errList, err) + if vs != nil { + vs.Errors = append(vs.Errors, err) return nil } return err } -func LoadEntry(ag *ActionGraph, nodesYaml map[string]any, validate bool, errs *[]error) error { +func LoadEntry(ag *ActionGraph, nodesYaml map[string]any, vs *ValidationState) error { entryAny, exists := nodesYaml["entry"] if !exists { - return collectOrReturn(CreateErr(nil, nil, "entry is missing"), validate, errs) + return collectOrReturn(CreateErr(nil, nil, "entry is missing"), vs) } entry, ok := entryAny.(string) if !ok { - return collectOrReturn(CreateErr(nil, nil, "entry is not a string"), validate, errs) + return collectOrReturn(CreateErr(nil, nil, "entry is not a string"), vs) } ag.SetEntry(entry) @@ -299,12 +310,8 @@ func RunGraph(ctx context.Context, graphName string, graphContent []byte, opts R if _, exists := opts.OverrideSecrets["GITHUB_TOKEN"]; !exists { if ghToken, ok := opts.OverrideEnv["GITHUB_TOKEN"]; ok && ghToken != "" { opts.OverrideSecrets["GITHUB_TOKEN"] = ghToken - } else if ghToken := os.Getenv("GITHUB_TOKEN"); ghToken != "" { + } else if ghToken := utils.GetGhTokenFromEnv(); ghToken != "" { opts.OverrideSecrets["GITHUB_TOKEN"] = ghToken - } else if inputToken := os.Getenv("INPUT_GITHUB_TOKEN"); inputToken != "" { - opts.OverrideSecrets["GITHUB_TOKEN"] = inputToken - } else if inputToken := os.Getenv("INPUT_TOKEN"); inputToken != "" { - opts.OverrideSecrets["GITHUB_TOKEN"] = inputToken } } delete(opts.OverrideEnv, "GITHUB_TOKEN") @@ -312,7 +319,7 @@ func RunGraph(ctx context.Context, graphName string, graphContent []byte, opts R os.Unsetenv("INPUT_GITHUB_TOKEN") os.Unsetenv("INPUT_TOKEN") - ag, errs := LoadGraph(graphYaml, nil, "", false, opts) + ag, errs := LoadGraph(graphYaml, nil, "", opts) if len(errs) > 0 { return CreateErr(nil, errs[0], "failed to load graph") } @@ -573,52 +580,51 @@ func RunGraph(ctx context.Context, graphName string, graphContent []byte, opts R return mainErr } -func LoadGraph(graphYaml map[string]any, parent NodeBaseInterface, parentId string, validate bool, opts RunOpts) (ActionGraph, []error) { - - var ( - collectedErrors []error - err error - ) +func LoadGraph(graphYaml map[string]any, parent NodeBaseInterface, parentId string, opts RunOpts) (ActionGraph, []error) { + vs := opts.VS ag := NewActionGraph() + var err error + ag.Inputs, err = LoadGraphInputs(graphYaml) if err != nil { - if !validate { + if collectOrReturn(err, vs) != nil { return ActionGraph{}, []error{err} } - collectedErrors = append(collectedErrors, err) } ag.Outputs, err = LoadGraphOutputs(graphYaml) if err != nil { - if !validate { + if collectOrReturn(err, vs) != nil { return ActionGraph{}, []error{err} } - collectedErrors = append(collectedErrors, err) } - err = LoadNodes(&ag, parent, parentId, graphYaml, validate, &collectedErrors, opts) - if err != nil && !validate { + err = LoadNodes(&ag, parent, parentId, graphYaml, opts) + if err != nil && vs == nil { return ActionGraph{}, []error{err} } - err = LoadExecutions(&ag, graphYaml, validate, &collectedErrors) - if err != nil && !validate { + err = LoadExecutions(&ag, graphYaml, vs) + if err != nil && vs == nil { return ActionGraph{}, []error{err} } - err = LoadConnections(&ag, graphYaml, parent, validate, &collectedErrors) - if err != nil && !validate { + err = LoadConnections(&ag, graphYaml, parent, vs) + if err != nil && vs == nil { return ActionGraph{}, []error{err} } - err = LoadEntry(&ag, graphYaml, validate, &collectedErrors) - if err != nil && !validate { + err = LoadEntry(&ag, graphYaml, vs) + if err != nil && vs == nil { return ActionGraph{}, []error{err} } - return ag, collectedErrors + if vs != nil { + return ag, vs.Errors + } + return ag, nil } func LoadGraphInputs(graphYaml map[string]any) (map[InputId]InputDefinition, error) { @@ -676,14 +682,14 @@ func anyToPortDefinition[T any](o any) (T, error) { return ret, err } -func LoadNodes(ag *ActionGraph, parent NodeBaseInterface, parentId string, nodesYaml map[string]any, validate bool, errs *[]error, opts RunOpts) error { +func LoadNodes(ag *ActionGraph, parent NodeBaseInterface, parentId string, nodesYaml map[string]any, opts RunOpts) error { nodesList, err := utils.GetTypedPropertyByPath[[]any](nodesYaml, "nodes") if err != nil { - return collectOrReturn(err, validate, errs) + return collectOrReturn(err, opts.VS) } for _, nodeData := range nodesList { - n, id, err := LoadNode(parent, parentId, nodeData, validate, errs, opts) + n, id, err := LoadNode(parent, parentId, nodeData, opts) if err != nil { return err } @@ -698,21 +704,24 @@ func LoadNodes(ag *ActionGraph, parent NodeBaseInterface, parentId string, nodes return nil } -func LoadNode(parent NodeBaseInterface, parentId string, nodeData any, validate bool, errs *[]error, opts RunOpts) (NodeBaseInterface, string, error) { +func LoadNode(parent NodeBaseInterface, parentId string, nodeData any, opts RunOpts) (NodeBaseInterface, string, error) { + vs := opts.VS nodeI, ok := nodeData.(map[string]any) if !ok { err := CreateErr(nil, nil, "node is not a map") - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return nil, "", err } return nil, "", nil } + validate := vs != nil + // We attempt to get the ID. If it fails, we record the error but CONTINUE // processing (if validating) to check Type, Inputs, and Outputs. id, idErr := utils.GetTypedPropertyByPath[string](nodeI, "id") if idErr != nil { - if err := collectOrReturn(idErr, validate, errs); err != nil { + if err := collectOrReturn(idErr, vs); err != nil { return nil, "", err } } @@ -721,7 +730,7 @@ func LoadNode(parent NodeBaseInterface, parentId string, nodeData any, validate // We must early out here. nodeType, typeErr := utils.GetTypedPropertyByPath[string](nodeI, "type") if typeErr != nil { - if err := collectOrReturn(typeErr, validate, errs); err != nil { + if err := collectOrReturn(typeErr, vs); err != nil { return nil, "", err } return nil, "", nil @@ -751,8 +760,7 @@ func LoadNode(parent NodeBaseInterface, parentId string, nodeData any, validate // Early out on first error if not validating return nil, "", factoryErrs[0] } - // Collect errors and proceed IF we have a valid node instance 'n' - *errs = append(*errs, factoryErrs...) + vs.Errors = append(vs.Errors, factoryErrs...) } // If the factory failed to produce a node instance completely (n is nil), @@ -775,13 +783,13 @@ func LoadNode(parent NodeBaseInterface, parentId string, nodeData any, validate // We continue to check inputs/outputs even if factoryErrs occurred, // provided 'n' exists. - inputErr := LoadInputValues(n, nodeI, validate, errs) + inputErr := LoadInputValues(n, nodeI, vs) if inputErr != nil && !validate { return nil, "", inputErr } // Validate Outputs - outputErr := LoadOutputValues(n, nodeI, validate, errs) + outputErr := LoadOutputValues(n, nodeI, vs) if outputErr != nil && !validate { return nil, "", outputErr } @@ -801,17 +809,17 @@ func LoadNode(parent NodeBaseInterface, parentId string, nodeData any, validate return n, id, nil } -func LoadInputValues(node NodeBaseInterface, nodeI map[string]any, validate bool, errs *[]error) error { +func LoadInputValues(node NodeBaseInterface, nodeI map[string]any, vs *ValidationState) error { inputs, hasInputs := node.(HasInputsInterface) inputValues, err := utils.GetTypedPropertyByPath[map[string]any](nodeI, "inputs") if err != nil { if errors.Is(err, &utils.ErrPropertyNotFound{}) { return nil } - return collectOrReturn(err, validate, errs) + return collectOrReturn(err, vs) } if !hasInputs { - return collectOrReturn(CreateErr(nil, nil, "dst node '%s' (%s) does not have inputs but inputs are defined", node.GetName(), node.GetId()), validate, errs) + return collectOrReturn(CreateErr(nil, nil, "dst node '%s' (%s) does not have inputs but inputs are defined", node.GetName(), node.GetId()), vs) } type subInput struct { @@ -836,7 +844,7 @@ func LoadInputValues(node NodeBaseInterface, nodeI map[string]any, validate bool _, _, ok := inputs.InputDefByPortId(groupInputId) if !ok { err := CreateErr(nil, nil, "dst node '%s' (%s) has no array input '%s'", node.GetName(), node.GetId(), groupInputId) - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } continue @@ -848,9 +856,22 @@ func LoadInputValues(node NodeBaseInterface, nodeI map[string]any, validate bool }) } + // In validation mode, check that the input actually exists in the + // node's definition. At runtime we skip this because SetInputValue + // silently stores any key and unknown inputs are simply ignored. + if vs != nil && !isIndexPort { + if _, _, ok := inputs.InputDefByPortId(portId); !ok { + err := CreateErr(nil, nil, "dst node '%s' (%s) has no input '%s'", node.GetName(), node.GetId(), portId) + if collectOrReturn(err, vs) != nil { + return err + } + continue + } + } + err = inputs.SetInputValue(InputId(portId), inputValue) if err != nil { - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } continue @@ -867,7 +888,7 @@ func LoadInputValues(node NodeBaseInterface, nodeI map[string]any, validate bool for _, subInput := range subInputs { err = inputs.AddSubInput(subInput.PortId, groupInputId, subInput.PortIndex) if err != nil { - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } } @@ -876,7 +897,7 @@ func LoadInputValues(node NodeBaseInterface, nodeI map[string]any, validate bool return nil } -func LoadOutputValues(node NodeBaseInterface, nodeI map[string]any, validate bool, errs *[]error) error { +func LoadOutputValues(node NodeBaseInterface, nodeI map[string]any, vs *ValidationState) error { outputs, hasOutputs := node.(HasOutputsInterface) outputValues, err := utils.GetTypedPropertyByPath[map[string]any](nodeI, "outputs") if err != nil { @@ -885,7 +906,7 @@ func LoadOutputValues(node NodeBaseInterface, nodeI map[string]any, validate boo } } if !hasOutputs { - return collectOrReturn(CreateErr(nil, nil, "node '%s' (%s) does not have outputs but outputs are defined", node.GetName(), node.GetId()), validate, errs) + return collectOrReturn(CreateErr(nil, nil, "node '%s' (%s) does not have outputs but outputs are defined", node.GetName(), node.GetId()), vs) } type subOutput struct { @@ -901,7 +922,7 @@ func LoadOutputValues(node NodeBaseInterface, nodeI map[string]any, validate boo _, _, ok := outputs.OutputDefByPortId(arrayOutputId) if !ok { err := CreateErr(nil, nil, "source node '%s' (%s) has no array output '%s'", node.GetName(), node.GetId(), arrayOutputId) - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } continue @@ -914,7 +935,7 @@ func LoadOutputValues(node NodeBaseInterface, nodeI map[string]any, validate boo } else { // at the moment output values can only be used to define an output port err := CreateErr(nil, nil, "source node '%s' (%s) has no output '%s'", node.GetName(), node.GetId(), portId) - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } } @@ -930,7 +951,7 @@ func LoadOutputValues(node NodeBaseInterface, nodeI map[string]any, validate boo for _, subOutput := range subOutputs { err = outputs.AddSubOutput(subOutput.PortId, arrayOutputId, subOutput.PortIndex) if err != nil { - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } } @@ -939,18 +960,18 @@ func LoadOutputValues(node NodeBaseInterface, nodeI map[string]any, validate boo return nil } -func LoadExecutions(ag *ActionGraph, nodesYaml map[string]any, validate bool, errs *[]error) error { +func LoadExecutions(ag *ActionGraph, nodesYaml map[string]any, vs *ValidationState) error { executionsList, err := utils.GetTypedPropertyByPath[[]any](nodesYaml, "executions") if err != nil { - return collectOrReturn(err, validate, errs) + return collectOrReturn(err, vs) } for _, executions := range executionsList { c, ok := executions.(map[string]any) if !ok { err := CreateErr(nil, nil, "execution is not a map") - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } continue @@ -958,7 +979,7 @@ func LoadExecutions(ag *ActionGraph, nodesYaml map[string]any, validate bool, er srcNodeId, err := utils.GetTypedPropertyByPath[string](c, "src.node") if err != nil { - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } continue @@ -966,7 +987,7 @@ func LoadExecutions(ag *ActionGraph, nodesYaml map[string]any, validate bool, er dstNodeId, err := utils.GetTypedPropertyByPath[string](c, "dst.node") if err != nil { - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } continue @@ -974,7 +995,7 @@ func LoadExecutions(ag *ActionGraph, nodesYaml map[string]any, validate bool, er srcPort, err := utils.GetTypedPropertyByPath[string](c, "src.port") if err != nil { - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } continue @@ -982,7 +1003,7 @@ func LoadExecutions(ag *ActionGraph, nodesYaml map[string]any, validate bool, er dstPort, err := utils.GetTypedPropertyByPath[string](c, "dst.port") if err != nil { - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } continue @@ -991,7 +1012,7 @@ func LoadExecutions(ag *ActionGraph, nodesYaml map[string]any, validate bool, er srcNode, ok := ag.FindNode(srcNodeId) if !ok { err := CreateErr(nil, nil, "src node '%s' does not exist", srcNodeId) - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } continue @@ -1000,7 +1021,7 @@ func LoadExecutions(ag *ActionGraph, nodesYaml map[string]any, validate bool, er dstNode, ok := ag.FindNode(dstNodeId) if !ok { err := CreateErr(nil, nil, "connection dst node '%s' does not exist", dstNodeId) - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } continue @@ -1009,7 +1030,7 @@ func LoadExecutions(ag *ActionGraph, nodesYaml map[string]any, validate bool, er srcExecNode, ok := srcNode.(HasExecutionInterface) if !ok { err := CreateErr(nil, err, "src node '%s' (%s) does not have an execution interface", srcNode.GetName(), srcNodeId) - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } continue @@ -1020,7 +1041,7 @@ func LoadExecutions(ag *ActionGraph, nodesYaml map[string]any, validate bool, er srcOutputNode, ok := srcNode.(HasOutputsInterface) if !ok { err := CreateErr(nil, err, "src node '%s' (%s) does not have an output interface", srcNode.GetName(), srcNodeId) - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } continue @@ -1029,7 +1050,7 @@ func LoadExecutions(ag *ActionGraph, nodesYaml map[string]any, validate bool, er _, _, ok = srcOutputNode.OutputDefByPortId(srcPort) if !ok { err := CreateErr(nil, nil, "src node '%s' (%s) has no execution output '%s'", srcNode.GetName(), srcNodeId, srcPort) - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } continue @@ -1041,7 +1062,7 @@ func LoadExecutions(ag *ActionGraph, nodesYaml map[string]any, validate bool, er dstInputNode, ok := dstNode.(HasInputsInterface) if !ok { err := CreateErr(nil, err, "dst node '%s' ('%s') does not have an input interface", dstNode.GetName(), dstNodeId) - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } continue @@ -1050,7 +1071,7 @@ func LoadExecutions(ag *ActionGraph, nodesYaml map[string]any, validate bool, er _, _, ok = dstInputNode.InputDefByPortId(dstPort) if !ok { err := CreateErr(nil, nil, "dst node '%s' (%s) has no execution input '%s'", dstNode.GetName(), dstNodeId, dstPort) - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } continue @@ -1059,7 +1080,7 @@ func LoadExecutions(ag *ActionGraph, nodesYaml map[string]any, validate bool, er err = srcExecNode.ConnectExecutionPort(srcNode, OutputId(srcPort), dstNode, InputId(dstPort)) if err != nil { - if collectOrReturn(CreateErr(nil, err, "failed to connect execution ports"), validate, errs) != nil { + if collectOrReturn(CreateErr(nil, err, "failed to connect execution ports"), vs) != nil { return err } continue @@ -1068,18 +1089,18 @@ func LoadExecutions(ag *ActionGraph, nodesYaml map[string]any, validate bool, er return nil } -func LoadConnections(ag *ActionGraph, nodesYaml map[string]any, parent NodeBaseInterface, validate bool, errs *[]error) error { +func LoadConnections(ag *ActionGraph, nodesYaml map[string]any, parent NodeBaseInterface, vs *ValidationState) error { connectionsList, err := utils.GetTypedPropertyByPath[[]any](nodesYaml, "connections") if err != nil { - return collectOrReturn(err, validate, errs) + return collectOrReturn(err, vs) } for _, connection := range connectionsList { c, ok := connection.(map[string]any) if !ok { err := CreateErr(nil, nil, "connection is not a map") - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } continue @@ -1087,7 +1108,7 @@ func LoadConnections(ag *ActionGraph, nodesYaml map[string]any, parent NodeBaseI srcNodeId, err := utils.GetTypedPropertyByPath[string](c, "src.node") if err != nil { - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } continue @@ -1095,7 +1116,7 @@ func LoadConnections(ag *ActionGraph, nodesYaml map[string]any, parent NodeBaseI dstNodeId, err := utils.GetTypedPropertyByPath[string](c, "dst.node") if err != nil { - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } continue @@ -1103,7 +1124,7 @@ func LoadConnections(ag *ActionGraph, nodesYaml map[string]any, parent NodeBaseI srcPort, err := utils.GetTypedPropertyByPath[string](c, "src.port") if err != nil { - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } continue @@ -1111,7 +1132,7 @@ func LoadConnections(ag *ActionGraph, nodesYaml map[string]any, parent NodeBaseI dstPort, err := utils.GetTypedPropertyByPath[string](c, "dst.port") if err != nil { - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } continue @@ -1120,7 +1141,7 @@ func LoadConnections(ag *ActionGraph, nodesYaml map[string]any, parent NodeBaseI srcNode, ok := ag.FindNode(srcNodeId) if !ok { err := CreateErr(nil, nil, "src node '%s' does not exist", srcNodeId) - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } continue @@ -1129,7 +1150,7 @@ func LoadConnections(ag *ActionGraph, nodesYaml map[string]any, parent NodeBaseI dstNode, ok := ag.FindNode(dstNodeId) if !ok { err := CreateErr(nil, nil, "connection dst node '%s' does not exist", dstNodeId) - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } continue @@ -1138,7 +1159,7 @@ func LoadConnections(ag *ActionGraph, nodesYaml map[string]any, parent NodeBaseI dstInputNode, ok := dstNode.(HasInputsInterface) if !ok { err := CreateErr(nil, err, "dst node '%s' ('%s') does not have an input interface", dstNode.GetName(), dstNodeId) - if collectOrReturn(err, validate, errs) != nil { + if collectOrReturn(err, vs) != nil { return err } continue @@ -1150,7 +1171,7 @@ func LoadConnections(ag *ActionGraph, nodesYaml map[string]any, parent NodeBaseI SkipValidation: strings.HasPrefix(srcNode.GetNodeTypeId(), "core/group@") || strings.HasPrefix(dstNode.GetNodeTypeId(), "core/group@"), }) if err != nil { - if collectOrReturn(CreateErr(nil, err, "failed to connect data ports"), validate, errs) != nil { + if collectOrReturn(CreateErr(nil, err, "failed to connect data ports"), vs) != nil { return err } continue diff --git a/core/inputs.go b/core/inputs.go index 80728b0..f60c70c 100644 --- a/core/inputs.go +++ b/core/inputs.go @@ -306,9 +306,6 @@ func (n *Inputs) SetInputDefs(inputDefs map[InputId]InputDefinition, opts SetDef } func (n *Inputs) SetInputValue(inputId InputId, value any) error { - // TODO: (Seb) Ensure that only input values are set - // that are defined in the node definition. - if n.inputValues == nil { n.inputValues = make(map[InputId]any) } diff --git a/nodes/gh-action@v1.go b/nodes/gh-action@v1.go index 5c5789c..d8e2ca1 100644 --- a/nodes/gh-action@v1.go +++ b/nodes/gh-action@v1.go @@ -62,16 +62,48 @@ type GhActionNode struct { core.Outputs core.Executions - actionName string - actionType ActionType // docker or node - actionRuns ActionRuns - actionRunJsPath string - actionPostJsPath string - actionDir string + actionName string + actionType ActionType // docker or node + actionRuns ActionRuns + actionRunJsPath string + actionPostJsPath string + actionDir string + + // isProxy indicates whether this node is a proxy created during + // validation when the action.yml couldn't be fetched for validation. + isProxy bool Data DockerData } +// InputDefByPortId returns a generic string definition for any unknown port +// when the node is a proxy. See GhActionNode.isProxy +func (n *GhActionNode) InputDefByPortId(inputId string) (core.InputDefinition, *core.IndexPortInfo, bool) { + def, info, ok := n.Inputs.InputDefByPortId(inputId) + if ok || !n.isProxy { + return def, info, ok + } + + // since we couldn't validate the actual input, just return a dummy string input. + return core.InputDefinition{ + PortDefinition: core.PortDefinition{Name: inputId, Type: "string"}, + }, nil, true +} + +// OutputDefByPortId returns a generic string definition for any unknown port +// when the node is a proxy. See GhActionNode.isProxy +func (n *GhActionNode) OutputDefByPortId(outputId string) (core.OutputDefinition, *core.IndexPortInfo, bool) { + def, info, ok := n.Outputs.OutputDefByPortId(outputId) + if ok || !n.isProxy { + return def, info, ok + } + + // since we couldn't validate the actual output, just return a dummy string output. + return core.OutputDefinition{ + PortDefinition: core.PortDefinition{Name: outputId, Type: "string"}, + }, nil, true +} + func (n *GhActionNode) ExecuteImpl(c *core.ExecutionState, inputId core.InputId, prevError error) error { currentEnvMap := c.GetContextEnvironMapCopy() @@ -541,10 +573,25 @@ func init() { actionDir := filepath.Join(actionRepoRoot, path) isGitHubAction := opts.OverrideEnv["GITHUB_ACTIONS"] == "true" || os.Getenv("GITHUB_ACTIONS") == "true" - if !isGitHubAction { + if !isGitHubAction && !validate { return nil, []error{core.CreateErr(nil, nil, "node representing GitHub Action '%v' can only be used in a GitHub Actions workflow.", nodeType)} } + node := &GhActionNode{} + + // Common ports shared by both proxy and fully-resolved nodes. + inputs := map[core.InputId]core.InputDefinition{ + "exec": {PortDefinition: core.PortDefinition{Exec: true}}, + "env": { + PortDefinition: core.PortDefinition{Name: "Environment Vars", Type: "[]string"}, + Hint: "MY_ENV=1234", + }, + } + outputs := map[core.OutputId]core.OutputDefinition{ + "exec-success": {PortDefinition: core.PortDefinition{Exec: true}}, + "exec-err": {PortDefinition: core.PortDefinition{Exec: true}}, + } + ghToken := opts.OverrideSecrets["GITHUB_TOKEN"] // TODO: (Seb) for the validation process we only need the action.yml, not the entire repo @@ -552,6 +599,17 @@ func init() { _, err = os.Stat(actionRepoRoot) if errors.Is(err, os.ErrNotExist) { if ghToken == "" { + if validate { + // No token and repo is not cached so we return a proxy node so + // graph structure can still be validated. + node.isProxy = true + node.SetInputDefs(inputs, core.SetDefsOpts{}) + node.SetOutputDefs(outputs, core.SetDefsOpts{}) + node.SetNodeType(nodeType) + opts.VS.Warnings = append(opts.VS.Warnings, + fmt.Sprintf("node '%s': GITHUB_TOKEN not set — cannot verify that the action exists or that its inputs and outputs are correct", nodeType)) + return node, nil + } return nil, []error{core.CreateErr(nil, nil, "neither GITHUB_TOKEN nor INPUT_GITHUB_TOKEN are set")} } @@ -648,11 +706,9 @@ func init() { return nil, []error{err} } - node := &GhActionNode{ - actionName: action.Name, - actionRuns: action.Runs, - actionDir: actionDir, - } + node.actionName = action.Name + node.actionRuns = action.Runs + node.actionDir = actionDir switch action.Runs.Using { case "docker": @@ -748,42 +804,28 @@ func init() { return nil, []error{core.CreateErr(nil, nil, "unsupported action run type: %s", action.Runs.Using)} } - inputs := make(map[core.InputId]core.InputDefinition, 0) - if len(action.Inputs) > 0 { - for name, input := range action.Inputs { - pd := core.InputDefinition{ - PortDefinition: core.PortDefinition{ - Name: name, - Type: "string", - Desc: input.Desc, - }, - } - if input.Default != "" { - pd.Default = input.Default - } - inputs[core.InputId(name)] = pd + for name, input := range action.Inputs { + pd := core.InputDefinition{ + PortDefinition: core.PortDefinition{ + Name: name, + Type: "string", + Desc: input.Desc, + }, } - } - - outputs := make(map[core.OutputId]core.OutputDefinition, 0) - if len(action.Outputs) > 0 { - for name, output := range action.Outputs { - outputs[core.OutputId(name)] = core.OutputDefinition{ - PortDefinition: core.PortDefinition{ - Name: name, - Type: "string", - Desc: output.Description, - }, - } + if input.Default != "" { + pd.Default = input.Default } + inputs[core.InputId(name)] = pd } - inputs["exec"] = core.InputDefinition{PortDefinition: core.PortDefinition{Exec: true}} - outputs["exec-success"] = core.OutputDefinition{PortDefinition: core.PortDefinition{Exec: true}} - outputs["exec-err"] = core.OutputDefinition{PortDefinition: core.PortDefinition{Exec: true}} - inputs["env"] = core.InputDefinition{ - PortDefinition: core.PortDefinition{Name: "Environment Vars", Type: "[]string"}, - Hint: "MY_ENV=1234", + for name, output := range action.Outputs { + outputs[core.OutputId(name)] = core.OutputDefinition{ + PortDefinition: core.PortDefinition{ + Name: name, + Type: "string", + Desc: output.Description, + }, + } } node.SetInputDefs(inputs, core.SetDefsOpts{}) diff --git a/nodes/group@v1.go b/nodes/group@v1.go index 73d9d02..54f0cca 100644 --- a/nodes/group@v1.go +++ b/nodes/group@v1.go @@ -179,7 +179,12 @@ func init() { } } - ag, errs := core.LoadGraph(subGraph, group, parentId, validate, opts) + if validate { + opts.VS = &core.ValidationState{} + } else { + opts.VS = nil + } + ag, errs := core.LoadGraph(subGraph, group, parentId, opts) if len(errs) > 0 { if !validate { return nil, errs diff --git a/nodes/test@v1_test.go b/nodes/test@v1_test.go index 7d56f5d..bfcfbd6 100644 --- a/nodes/test@v1_test.go +++ b/nodes/test@v1_test.go @@ -356,7 +356,7 @@ executions: [] return nil, nil, nil, nil, err } - ag, errs := core.LoadGraph(graphYaml, nil, "", false, core.RunOpts{}) + ag, errs := core.LoadGraph(graphYaml, nil, "", core.RunOpts{}) if errs != nil { return nil, nil, nil, nil, errs[0] } diff --git a/tests_e2e/scripts/chatgpt_simulator.act b/tests_e2e/scripts/chatgpt_simulator.act index 4c4c484..3dd5385 100644 --- a/tests_e2e/scripts/chatgpt_simulator.act +++ b/tests_e2e/scripts/chatgpt_simulator.act @@ -354,7 +354,6 @@ nodes: x: -240 y: 320 inputs: - secret_env: SECRET_OPENAI_API_KEY name: OPENAI_API_KEY connections: - src: diff --git a/tests_unit/disable_concurrency_input_test.go b/tests_unit/disable_concurrency_input_test.go index a4b0bae..f09c2c8 100644 --- a/tests_unit/disable_concurrency_input_test.go +++ b/tests_unit/disable_concurrency_input_test.go @@ -18,7 +18,7 @@ func loadTestGraph(t *testing.T, graphStr string) core.ActionGraph { if err := yaml.Unmarshal([]byte(graphStr), &graphYaml); err != nil { t.Fatalf("unmarshal YAML: %v", err) } - ag, errs := core.LoadGraph(graphYaml, nil, "", false, core.RunOpts{}) + ag, errs := core.LoadGraph(graphYaml, nil, "", core.RunOpts{}) if len(errs) > 0 { t.Fatalf("LoadGraph: %v", errs[0]) } diff --git a/utils/utils.go b/utils/utils.go index b1c112f..caf3b33 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -21,6 +21,21 @@ var ( ORIGIN_ENV_DOTENV = "env (dotenv)" ) +// GetGhTokenFromEnv returns the GitHub token from environment variables, +// checking GITHUB_TOKEN, INPUT_GITHUB_TOKEN, and INPUT_TOKEN in order. +func GetGhTokenFromEnv() string { + if t := os.Getenv("GITHUB_TOKEN"); t != "" { + return t + } + if t := os.Getenv("INPUT_GITHUB_TOKEN"); t != "" { + return t + } + if t := os.Getenv("INPUT_TOKEN"); t != "" { + return t + } + return "" +} + func SafeCloseReaderAndIgnoreError(r io.Reader) { _ = SafeCloseReader(r) }