From c1196bc77f90136668fadcb23c42e7478a8d289a Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Sun, 15 Feb 2026 23:29:59 -0500 Subject: [PATCH 1/4] Add JSON schema for graph .act files --- actfile-schema.json | 475 ++++++++++++++++++ go.mod | 1 + go.sum | 2 + tests_unit/actfile_schema_test.go | 769 ++++++++++++++++++++++++++++++ 4 files changed, 1247 insertions(+) create mode 100644 actfile-schema.json create mode 100644 tests_unit/actfile_schema_test.go diff --git a/actfile-schema.json b/actfile-schema.json new file mode 100644 index 0000000..8a36ee1 --- /dev/null +++ b/actfile-schema.json @@ -0,0 +1,475 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://actionforge.dev/schemas/actfile.json", + "title": "ActionForge Graph File (.act)", + "description": "Schema for ActionForge .act graph files — YAML-based workflow definitions with nodes, data connections, and execution connections.", + "type": "object", + "required": ["entry", "nodes"], + "additionalProperties": false, + "properties": { + "editor": { + "description": "Metadata about the editor that created/updated this file. Not used during execution.", + "type": "object", + "additionalProperties": false, + "properties": { + "version": { + "type": "object", + "additionalProperties": false, + "properties": { + "created": { + "type": "string", + "description": "Editor version when the file was created (e.g. \"v1.26.5\").", + "pattern": "^v\\d+\\.\\d+\\.\\d+$" + }, + "updated": { + "type": "string", + "description": "Editor version when the file was last updated.", + "pattern": "^v\\d+\\.\\d+\\.\\d+$" + } + } + } + } + }, + "entry": { + "type": "string", + "description": "Node ID of the graph's entry point. Must reference a node that implements the entry interface (e.g. core/start@v1 or core/gh-start@v1 for top-level graphs, or core/group-inputs@v1 for group sub-graphs)." + }, + "type": { + "type": "string", + "description": "The graph type.", + "enum": ["generic", "standard", "group", "github"] + }, + "desc": { + "type": "string", + "description": "Human-readable description of the graph." + }, + "nodes": { + "type": "array", + "description": "Array of node definitions that make up the graph.", + "items": { + "$ref": "#/$defs/node" + } + }, + "connections": { + "type": "array", + "description": "Data-flow connections between node ports. Each connection links a source output port to a destination input port.", + "items": { + "$ref": "#/$defs/connection" + } + }, + "executions": { + "type": "array", + "description": "Execution-flow connections between node ports. Each execution link defines the order in which nodes execute.", + "items": { + "$ref": "#/$defs/execution" + } + }, + "inputs": { + "type": "object", + "description": "Graph-level input port definitions. Used in group sub-graphs to define the interface exposed to the parent graph.", + "additionalProperties": { + "$ref": "#/$defs/inputDefinition" + } + }, + "outputs": { + "type": "object", + "description": "Graph-level output port definitions. Used in group sub-graphs to define the interface exposed to the parent graph.", + "additionalProperties": { + "$ref": "#/$defs/outputDefinition" + } + }, + "info": { + "$ref": "#/$defs/graphInfo" + } + }, + "$defs": { + "node": { + "type": "object", + "description": "A single node in the graph.", + "required": ["id", "type"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for this node within the graph." + }, + "type": { + "type": "string", + "description": "Node type identifier. Built-in nodes use the format \"core/@v\" (e.g. \"core/start@v1\", \"core/run@v1\"). GitHub Action nodes use \"github.com//@\" (e.g. \"github.com/actions/checkout@v4\").", + "pattern": "^(core/[a-z0-9-]+@v\\d+|github\\.com/.+)$" + }, + "label": { + "type": "string", + "description": "Optional display label shown in the graph editor." + }, + "position": { + "$ref": "#/$defs/position" + }, + "dimensions": { + "$ref": "#/$defs/dimensions" + }, + "settings": { + "type": "object", + "description": "Visual settings for the graph editor.", + "additionalProperties": false, + "properties": { + "folded": { + "type": "boolean", + "description": "Whether the node is visually collapsed in the editor." + } + } + }, + "inputs": { + "type": "object", + "description": "Input values for the node's input ports. Keys are port IDs. Array ports use bracket notation (e.g. \"values[0]\", \"env[1]\"). Values can be any type — strings, numbers, booleans, null, arrays, or objects — depending on the port's type definition.", + "additionalProperties": true + }, + "outputs": { + "type": "object", + "description": "Output port declarations. Used for array output ports that need to be pre-declared with bracket notation (e.g. \"exec[0]\": null, \"exec[1]\": null). Values are typically null.", + "additionalProperties": true + }, + "comment": { + "type": "string", + "description": "An optional comment or annotation for the node." + }, + "graph": { + "$ref": "#/$defs/nestedGraph", + "description": "Nested sub-graph for group nodes (type: core/group@v1). Contains a complete graph definition with its own nodes, connections, executions, inputs, and outputs." + } + } + }, + "nestedGraph": { + "type": "object", + "description": "A nested graph definition used inside group nodes. Has the same structure as a top-level graph, with additional input/output interface definitions.", + "required": ["entry", "nodes"], + "additionalProperties": false, + "properties": { + "entry": { + "type": "string", + "description": "Node ID of the sub-graph's entry point (typically a core/group-inputs@v1 node)." + }, + "type": { + "type": "string", + "description": "Must be \"group\" for nested graphs.", + "const": "group" + }, + "desc": { + "type": "string", + "description": "Description of the sub-graph." + }, + "nodes": { + "type": "array", + "description": "Array of node definitions within the sub-graph.", + "items": { + "$ref": "#/$defs/node" + } + }, + "connections": { + "type": "array", + "description": "Data-flow connections within the sub-graph.", + "items": { + "$ref": "#/$defs/connection" + } + }, + "executions": { + "type": "array", + "description": "Execution-flow connections within the sub-graph.", + "items": { + "$ref": "#/$defs/execution" + } + }, + "inputs": { + "type": "object", + "description": "Input port definitions for the group interface, exposed to the parent graph.", + "additionalProperties": { + "$ref": "#/$defs/inputDefinition" + } + }, + "outputs": { + "type": "object", + "description": "Output port definitions for the group interface, exposed to the parent graph.", + "additionalProperties": { + "$ref": "#/$defs/outputDefinition" + } + }, + "info": { + "$ref": "#/$defs/graphInfo" + } + } + }, + "connection": { + "type": "object", + "description": "A data-flow connection from a source node's output port to a destination node's input port.", + "required": ["src", "dst"], + "additionalProperties": false, + "properties": { + "src": { + "$ref": "#/$defs/portReference", + "description": "Source node and output port." + }, + "dst": { + "$ref": "#/$defs/portReference", + "description": "Destination node and input port." + }, + "isLoop": { + "type": "boolean", + "description": "Marks this connection as a loop-back (i.e. the source is downstream of the destination). Self-referencing connections are valid.", + "default": false + } + } + }, + "execution": { + "type": "object", + "description": "An execution-flow connection from a source node's execution output port to a destination node's execution input port. Defines the control-flow order of the graph.", + "required": ["src", "dst"], + "additionalProperties": false, + "properties": { + "src": { + "$ref": "#/$defs/portReference", + "description": "Source node and execution output port (e.g. \"exec\", \"exec-success\", \"exec-then\")." + }, + "dst": { + "$ref": "#/$defs/portReference", + "description": "Destination node and execution input port (e.g. \"exec\")." + }, + "isLoop": { + "type": "boolean", + "description": "Marks this execution link as a loop-back.", + "default": false + } + } + }, + "portReference": { + "type": "object", + "description": "A reference to a specific port on a specific node.", + "required": ["node", "port"], + "additionalProperties": false, + "properties": { + "node": { + "type": "string", + "description": "The node ID." + }, + "port": { + "type": "string", + "description": "The port ID. May use bracket notation for array ports (e.g. \"values[0]\")." + } + } + }, + "position": { + "type": "object", + "description": "X/Y coordinates for the node's position in the graph editor.", + "required": ["x", "y"], + "additionalProperties": false, + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + } + }, + "dimensions": { + "type": "object", + "description": "Width and height of the node in the graph editor. Primarily used for comment nodes.", + "additionalProperties": false, + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + } + }, + "portType": { + "type": "string", + "description": "The data type of a port.", + "enum": [ + "string", + "number", + "bool", + "secret", + "option", + "stream", + "any", + "unknown", + "iterable", + "indexable", + "[]any", + "[]string", + "[]number", + "[]bool" + ] + }, + "inputDefinition": { + "type": "object", + "description": "Definition of an input port on a graph or group interface.", + "required": ["type", "index"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Human-readable name of the port." + }, + "type": { + "description": "Data type of the port. Empty string indicates an execution port (used with exec: true).", + "oneOf": [ + { "$ref": "#/$defs/portType" }, + { "type": "string", "const": "" } + ] + }, + "desc": { + "type": "string", + "description": "Description of the port." + }, + "index": { + "type": "integer", + "description": "Display order index of the port.", + "minimum": 0 + }, + "exec": { + "type": "boolean", + "description": "If true, this is an execution port rather than a data port.", + "default": false + }, + "array": { + "type": "boolean", + "description": "If true, this port accepts an array of values using bracket notation.", + "default": false + }, + "array_initial_count": { + "type": "integer", + "description": "Initial number of array elements to create in the editor.", + "minimum": 0 + }, + "hide_socket": { + "type": "boolean", + "description": "If true, the port socket is hidden in the editor UI.", + "default": false + }, + "default": { + "description": "Default value used during execution if no connection or user value is provided." + }, + "initial": { + "description": "Initial value used by the editor to prefill the field. Has no effect during execution." + }, + "required": { + "type": "boolean", + "description": "Whether this input must be provided.", + "default": false + }, + "options": { + "type": "array", + "description": "Available options for ports of type \"option\".", + "items": { + "$ref": "#/$defs/inputOption" + } + }, + "multiline": { + "type": "boolean", + "description": "For string-type ports: enables a multi-line text editor.", + "default": false + }, + "hint": { + "type": "string", + "description": "Hint text displayed in the editor UI." + }, + "array_hints": { + "type": "array", + "description": "Hint texts for individual array elements.", + "items": { + "type": "string" + } + }, + "step": { + "type": "number", + "description": "For number-type ports: the increment step for the editor." + } + } + }, + "outputDefinition": { + "type": "object", + "description": "Definition of an output port on a graph or group interface.", + "required": ["type", "index"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Human-readable name of the port." + }, + "type": { + "description": "Data type of the port. Empty string indicates an execution port (used with exec: true).", + "oneOf": [ + { "$ref": "#/$defs/portType" }, + { "type": "string", "const": "" } + ] + }, + "desc": { + "type": "string", + "description": "Description of the port." + }, + "index": { + "type": "integer", + "description": "Display order index of the port.", + "minimum": 0 + }, + "exec": { + "type": "boolean", + "description": "If true, this is an execution port rather than a data port.", + "default": false + }, + "array": { + "type": "boolean", + "description": "If true, this port produces an array of values.", + "default": false + }, + "array_initial_count": { + "type": "integer", + "description": "Initial number of array elements.", + "minimum": 0 + } + } + }, + "graphInfo": { + "type": "object", + "description": "Metadata about the graph or sub-graph author and versioning.", + "additionalProperties": false, + "properties": { + "author": { + "type": "string", + "description": "Author of the graph." + }, + "version": { + "type": "string", + "description": "Version of the graph." + }, + "contact": { + "type": "string", + "description": "Contact information for the graph author." + }, + "description": { + "type": "string", + "description": "Description of the graph." + } + } + }, + "inputOption": { + "type": "object", + "description": "A selectable option for an input port of type \"option\".", + "required": ["name", "value"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Display name of the option." + }, + "value": { + "type": "string", + "description": "Value of the option." + } + } + } + } +} diff --git a/go.mod b/go.mod index da117e1..fe36253 100644 --- a/go.mod +++ b/go.mod @@ -107,6 +107,7 @@ require ( github.com/pkoukk/tiktoken-go v0.1.8 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/skeema/knownhosts v1.3.2 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect diff --git a/go.sum b/go.sum index 6c8716c..84db7ce 100644 --- a/go.sum +++ b/go.sum @@ -231,6 +231,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/rossmacarthur/cases v0.3.0 h1:7rlsXK2qHb6mQUOX+/IGDO9YyFXqjiDiMpkHfIl54Yg= github.com/rossmacarthur/cases v0.3.0/go.mod h1:ebnckUNBu5QAJGxFNai/H0IN133rLze6gmoxJyYvHW0= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= diff --git a/tests_unit/actfile_schema_test.go b/tests_unit/actfile_schema_test.go new file mode 100644 index 0000000..bc4d9df --- /dev/null +++ b/tests_unit/actfile_schema_test.go @@ -0,0 +1,769 @@ +//go:build tests_unit + +package tests_unit + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" + "go.yaml.in/yaml/v4" +) + +func compileActfileSchema(t *testing.T) *jsonschema.Schema { + t.Helper() + + schemaPath := filepath.Join("..", "actfile-schema.json") + schemaData, err := os.ReadFile(schemaPath) + if err != nil { + t.Fatalf("failed to read schema file: %v", err) + } + + var schemaObj any + if err := json.Unmarshal(schemaData, &schemaObj); err != nil { + t.Fatalf("failed to parse schema JSON: %v", err) + } + + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource("actfile-schema.json", schemaObj); err != nil { + t.Fatalf("failed to add schema resource: %v", err) + } + + schema, err := compiler.Compile("actfile-schema.json") + if err != nil { + t.Fatalf("failed to compile schema: %v", err) + } + + return schema +} + +// parseAndValidate parses a YAML string and validates it against the schema. +func parseAndValidate(schema *jsonschema.Schema, yamlStr string) error { + var data any + if err := yaml.Unmarshal([]byte(yamlStr), &data); err != nil { + return err + } + return schema.Validate(convertToJSONCompatible(data)) +} + +// TestActFilesMatchSchema validates all .act files in the e2e test directory +// against the JSON schema. +func TestActFilesMatchSchema(t *testing.T) { + schema := compileActfileSchema(t) + + actDir := filepath.Join("..", "tests_e2e", "scripts") + actFiles, err := filepath.Glob(filepath.Join(actDir, "*.act")) + if err != nil { + t.Fatalf("failed to glob .act files: %v", err) + } + + if len(actFiles) == 0 { + t.Fatal("no .act files found in tests_e2e/scripts/") + } + + for _, actFile := range actFiles { + name := filepath.Base(actFile) + t.Run(name, func(t *testing.T) { + data, err := os.ReadFile(actFile) + if err != nil { + t.Fatalf("failed to read file: %v", err) + } + + var yamlData any + if err := yaml.Unmarshal(data, &yamlData); err != nil { + t.Fatalf("failed to parse YAML: %v", err) + } + + if err := schema.Validate(convertToJSONCompatible(yamlData)); err != nil { + t.Errorf("schema validation failed:\n%v", err) + } + }) + } +} + +// TestActfileSchemaRejectsInvalid verifies that the schema actually catches +// structurally broken .act files. Each sub-test provides a YAML document that +// violates a specific schema constraint and must be rejected. +func TestActfileSchemaRejectsInvalid(t *testing.T) { + schema := compileActfileSchema(t) + + tests := []struct { + name string + yaml string + }{ + { + name: "missing entry", + yaml: ` +nodes: + - id: n1 + type: core/start@v1 + position: {x: 0, y: 0} +`, + }, + { + name: "missing nodes", + yaml: ` +entry: n1 +`, + }, + { + name: "entry wrong type", + yaml: ` +entry: 42 +nodes: [] +`, + }, + { + name: "nodes not an array", + yaml: ` +entry: n1 +nodes: + id: n1 + type: core/start@v1 +`, + }, + { + name: "node missing id", + yaml: ` +entry: n1 +nodes: + - type: core/start@v1 + position: {x: 0, y: 0} +`, + }, + { + name: "node missing type", + yaml: ` +entry: n1 +nodes: + - id: n1 + position: {x: 0, y: 0} +`, + }, + { + name: "unknown top-level property", + yaml: ` +entry: n1 +nodes: [] +foobar: true +`, + }, + { + name: "unknown node property", + yaml: ` +entry: n1 +nodes: + - id: n1 + type: core/start@v1 + position: {x: 0, y: 0} + foobar: true +`, + }, + { + name: "connection missing src", + yaml: ` +entry: n1 +nodes: + - id: n1 + type: core/start@v1 +connections: + - dst: {node: n1, port: exec} +`, + }, + { + name: "connection missing dst", + yaml: ` +entry: n1 +nodes: + - id: n1 + type: core/start@v1 +connections: + - src: {node: n1, port: exec} +`, + }, + { + name: "connection src missing node", + yaml: ` +entry: n1 +nodes: + - id: n1 + type: core/start@v1 +connections: + - src: {port: value} + dst: {node: n1, port: value} +`, + }, + { + name: "connection src missing port", + yaml: ` +entry: n1 +nodes: + - id: n1 + type: core/start@v1 +connections: + - src: {node: n1} + dst: {node: n1, port: value} +`, + }, + { + name: "connection unknown property", + yaml: ` +entry: n1 +nodes: + - id: n1 + type: core/start@v1 +connections: + - src: {node: n1, port: value} + dst: {node: n1, port: value} + foo: bar +`, + }, + { + name: "execution missing src", + yaml: ` +entry: n1 +nodes: + - id: n1 + type: core/start@v1 +executions: + - dst: {node: n1, port: exec} +`, + }, + { + name: "execution missing dst", + yaml: ` +entry: n1 +nodes: + - id: n1 + type: core/start@v1 +executions: + - src: {node: n1, port: exec} +`, + }, + { + name: "execution unknown property", + yaml: ` +entry: n1 +nodes: + - id: n1 + type: core/start@v1 +executions: + - src: {node: n1, port: exec} + dst: {node: n1, port: exec} + weight: 5 +`, + }, + { + name: "isLoop wrong type in connection", + yaml: ` +entry: n1 +nodes: + - id: n1 + type: core/start@v1 +connections: + - src: {node: n1, port: value} + dst: {node: n1, port: value} + isLoop: "yes" +`, + }, + { + name: "isLoop wrong type in execution", + yaml: ` +entry: n1 +nodes: + - id: n1 + type: core/start@v1 +executions: + - src: {node: n1, port: exec} + dst: {node: n1, port: exec} + isLoop: 1 +`, + }, + { + name: "top-level type invalid enum", + yaml: ` +entry: n1 +type: invalid +nodes: [] +`, + }, + { + name: "nested graph missing entry", + yaml: ` +entry: g1 +nodes: + - id: g1 + type: core/group@v1 + graph: + nodes: [] +`, + }, + { + name: "nested graph missing nodes", + yaml: ` +entry: g1 +nodes: + - id: g1 + type: core/group@v1 + graph: + entry: gi +`, + }, + { + name: "nested graph unknown property", + yaml: ` +entry: g1 +nodes: + - id: g1 + type: core/group@v1 + graph: + entry: gi + nodes: [] + foobar: true +`, + }, + { + name: "nested graph type not group", + yaml: ` +entry: g1 +nodes: + - id: g1 + type: core/group@v1 + graph: + entry: gi + type: generic + nodes: [] +`, + }, + { + name: "input definition missing type", + yaml: ` +entry: n1 +nodes: [] +inputs: + my-input: + name: My Input + index: 0 +`, + }, + { + name: "input definition missing index", + yaml: ` +entry: n1 +nodes: [] +inputs: + my-input: + name: My Input + type: string +`, + }, + { + name: "input definition unknown property", + yaml: ` +entry: n1 +nodes: [] +inputs: + my-input: + name: My Input + type: string + index: 0 + foobar: true +`, + }, + { + name: "output definition missing type", + yaml: ` +entry: n1 +nodes: [] +outputs: + my-output: + name: My Output + index: 0 +`, + }, + { + name: "output definition missing index", + yaml: ` +entry: n1 +nodes: [] +outputs: + my-output: + name: My Output + type: string +`, + }, + { + name: "output definition unknown property", + yaml: ` +entry: n1 +nodes: [] +outputs: + my-output: + name: My Output + type: string + index: 0 + foobar: true +`, + }, + { + name: "position unknown property", + yaml: ` +entry: n1 +nodes: + - id: n1 + type: core/start@v1 + position: {x: 0, y: 0, z: 0} +`, + }, + { + name: "editor unknown property", + yaml: ` +editor: + version: + created: v1.0.0 + theme: dark +entry: n1 +nodes: [] +`, + }, + { + name: "settings unknown property", + yaml: ` +entry: n1 +nodes: + - id: n1 + type: core/start@v1 + settings: + folded: false + color: red +`, + }, + { + name: "port reference unknown property", + yaml: ` +entry: n1 +nodes: + - id: n1 + type: core/start@v1 +connections: + - src: {node: n1, port: value, index: 0} + dst: {node: n1, port: value} +`, + }, + { + name: "info unknown property", + yaml: ` +entry: n1 +nodes: [] +info: + author: test + version: "1.0" + contact: test@test.com + description: Test + license: MIT +`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := parseAndValidate(schema, tc.yaml) + if err == nil { + t.Errorf("expected schema validation to fail for %q, but it passed", tc.name) + } + }) + } +} + +// TestActfileSchemaAcceptsValid verifies that the schema accepts well-formed +// .act documents covering all major structural features. +func TestActfileSchemaAcceptsValid(t *testing.T) { + schema := compileActfileSchema(t) + + tests := []struct { + name string + yaml string + }{ + { + name: "minimal graph", + yaml: ` +entry: n1 +nodes: + - id: n1 + type: core/start@v1 +`, + }, + { + name: "full graph with connections and executions", + yaml: ` +entry: start +type: generic +desc: A test graph +nodes: + - id: start + type: core/start@v1 + position: {x: 0, y: 0} + - id: print + type: core/print@v1 + position: {x: 200, y: 0} + inputs: + "values[0]": hello +connections: + - src: {node: start, port: args} + dst: {node: print, port: "values[0]"} + isLoop: false +executions: + - src: {node: start, port: exec} + dst: {node: print, port: exec} +`, + }, + { + name: "graph with editor metadata", + yaml: ` +editor: + version: + created: v1.26.5 + updated: v1.34.0 +entry: n1 +nodes: + - id: n1 + type: core/start@v1 +`, + }, + { + name: "node with all optional fields", + yaml: ` +entry: n1 +nodes: + - id: n1 + type: core/start@v1 + label: My Start Node + comment: This is a comment + position: {x: 100, y: -50} + dimensions: {width: 300, height: 200} + settings: + folded: true + inputs: + value: test + outputs: + exec[0]: null +`, + }, + { + name: "group node with nested graph", + yaml: ` +entry: g1 +nodes: + - id: g1 + type: core/group@v1 + position: {x: 0, y: 0} + graph: + entry: gi + type: group + nodes: + - id: gi + type: core/group-inputs@v1 + position: {x: 0, y: 0} + - id: go + type: core/group-outputs@v1 + position: {x: 200, y: 0} + connections: [] + executions: + - src: {node: gi, port: exec} + dst: {node: go, port: exec} + inputs: + exec-in: + type: "" + index: 0 + exec: true + outputs: + exec-out: + type: "" + index: 0 + exec: true +`, + }, + { + name: "graph-level inputs and outputs", + yaml: ` +entry: n1 +nodes: + - id: n1 + type: core/group-inputs@v1 +inputs: + my-string: + name: My String + type: string + index: 0 + desc: A string input + required: true + default: hello + multiline: true + hint: Enter a value + my-number: + type: number + index: 1 + step: 0.5 + my-option: + name: Choice + type: option + index: 2 + options: + - name: Option A + value: a + - name: Option B + value: b + my-array: + type: string + index: 3 + array: true + array_initial_count: 2 + array_hints: + - First item + - Second item + my-exec: + type: "" + index: 4 + exec: true + my-hidden: + type: bool + index: 5 + hide_socket: true + initial: true +outputs: + result: + name: Result + type: string + index: 0 + desc: The output + exec-done: + type: "" + index: 1 + exec: true +`, + }, + { + name: "loop-back connections", + yaml: ` +entry: start +nodes: + - id: start + type: core/start@v1 + - id: loop + type: core/array-add@v1 +connections: + - src: {node: loop, port: array} + dst: {node: loop, port: array} + isLoop: true +executions: + - src: {node: start, port: exec} + dst: {node: loop, port: exec} + - src: {node: loop, port: exec} + dst: {node: loop, port: exec} + isLoop: true +`, + }, + { + name: "github action node type", + yaml: ` +entry: start +nodes: + - id: start + type: core/gh-start@v1 + - id: checkout + type: github.com/actions/checkout@v4 + inputs: + fetch-depth: 0 +`, + }, + { + name: "graph with info block", + yaml: ` +entry: n1 +nodes: + - id: n1 + type: core/start@v1 +info: + author: Test Author + version: "1.0" + contact: author@example.com + description: A test graph +`, + }, + { + name: "all port types in inputs", + yaml: ` +entry: n1 +nodes: + - id: n1 + type: core/group-inputs@v1 +inputs: + p-string: {type: string, index: 0} + p-number: {type: number, index: 1} + p-bool: {type: bool, index: 2} + p-secret: {type: secret, index: 3} + p-option: {type: option, index: 4} + p-stream: {type: stream, index: 5} + p-any: {type: any, index: 6} + p-iterable: {type: iterable, index: 7} + p-indexable: {type: indexable, index: 8} + p-arr-any: {type: "[]any", index: 9} + p-arr-string: {type: "[]string", index: 10} + p-arr-number: {type: "[]number", index: 11} + p-arr-bool: {type: "[]bool", index: 12} + p-exec: {type: "", index: 13, exec: true} + p-unknown: {type: unknown, index: 14} +`, + }, + { + name: "empty optional sections", + yaml: ` +entry: n1 +nodes: + - id: n1 + type: core/start@v1 +connections: [] +executions: [] +`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := parseAndValidate(schema, tc.yaml) + if err != nil { + t.Errorf("expected valid document to pass, got:\n%v", err) + } + }) + } +} + +// convertToJSONCompatible recursively converts YAML-unmarshalled data into +// types that the JSON schema validator accepts. In particular, it converts +// integer types to float64 (JSON's number type). +func convertToJSONCompatible(v any) any { + switch val := v.(type) { + case map[string]any: + result := make(map[string]any, len(val)) + for k, v := range val { + result[k] = convertToJSONCompatible(v) + } + return result + case []any: + result := make([]any, len(val)) + for i, v := range val { + result[i] = convertToJSONCompatible(v) + } + return result + case int: + return float64(val) + case int64: + return float64(val) + case float32: + return float64(val) + default: + return val + } +} From ba3347e0dbd8a040c1ece9ad8481ec0d89fe55b0 Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Mon, 16 Feb 2026 00:37:57 -0500 Subject: [PATCH 2/4] Add JSON schema to validation step for validate command --- cmd/cmd_validate.go | 68 ++++++++++++++++++++++++++++++++++++++++++++- go.mod | 2 +- main.go | 6 ++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/cmd/cmd_validate.go b/cmd/cmd_validate.go index 1fae746..05a8f5f 100644 --- a/cmd/cmd_validate.go +++ b/cmd/cmd_validate.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "fmt" "os" "os/user" @@ -8,10 +9,14 @@ import ( "github.com/actionforge/actrun-cli/core" u "github.com/actionforge/actrun-cli/utils" + "github.com/santhosh-tekuri/jsonschema/v6" "github.com/spf13/cobra" "go.yaml.in/yaml/v4" ) +// ActfileSchema holds the embedded JSON schema bytes, set from main. +var ActfileSchema []byte + var cmdValidate = &cobra.Command{ Use: "validate [graph-file]", Short: "Validate a graph file.", @@ -38,6 +43,56 @@ var cmdValidate = &cobra.Command{ }, } +func validateSchema(data any) error { + if len(ActfileSchema) == 0 { + return fmt.Errorf("actfile schema not loaded") + } + + var schemaObj any + if err := json.Unmarshal(ActfileSchema, &schemaObj); err != nil { + return fmt.Errorf("failed to parse schema JSON: %w", err) + } + + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource("actfile-schema.json", schemaObj); err != nil { + return fmt.Errorf("failed to add schema resource: %w", err) + } + + schema, err := compiler.Compile("actfile-schema.json") + if err != nil { + return fmt.Errorf("failed to compile schema: %w", err) + } + + return schema.Validate(convertToJSONCompatible(data)) +} + +// convertToJSONCompatible recursively converts YAML-unmarshalled data into +// types that the JSON schema validator accepts. +func convertToJSONCompatible(v any) any { + switch val := v.(type) { + case map[string]any: + result := make(map[string]any, len(val)) + for k, v := range val { + result[k] = convertToJSONCompatible(v) + } + return result + case []any: + result := make([]any, len(val)) + for i, v := range val { + result[i] = convertToJSONCompatible(v) + } + return result + case int: + return float64(val) + case int64: + return float64(val) + case float32: + return float64(val) + default: + return val + } +} + func validateGraph(filePath string) error { fmt.Printf("Validating '%s'...\n", filePath) @@ -54,10 +109,17 @@ func validateGraph(filePath string) error { return err } + hasErrors := false + + if err := validateSchema(graphYaml); err != nil { + fmt.Printf("\n❌ Graph schema validation failed:\n%v\n", err) + hasErrors = true + } + _, errs := core.LoadGraph(graphYaml, nil, "", true, core.RunOpts{}) if len(errs) > 0 { - fmt.Printf("\n❌ Validation failed with %d error(s):\n", len(errs)) + fmt.Printf("\n❌ Graph validation failed with %d error(s):\n", len(errs)) for i, e := range errs { if leafErr, ok := e.(*core.LeafError); ok { @@ -67,6 +129,10 @@ func validateGraph(filePath string) error { fmt.Printf("\n%d. %v\n", i+1, e) } } + hasErrors = true + } + + if hasErrors { return fmt.Errorf("validation failed") } diff --git a/go.mod b/go.mod index fe36253..8c21be6 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/rhysd/actionlint v1.7.10 github.com/rossmacarthur/cases v0.3.0 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -107,7 +108,6 @@ require ( github.com/pkoukk/tiktoken-go v0.1.8 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/skeema/knownhosts v1.3.2 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect diff --git a/main.go b/main.go index c33b6fd..178bc37 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,8 @@ package main import ( + _ "embed" + _ "github.com/actionforge/actrun-cli/api" "github.com/actionforge/actrun-cli/cmd" _ "github.com/actionforge/actrun-cli/cmd" @@ -9,6 +11,9 @@ import ( "github.com/actionforge/actrun-cli/utils" ) +//go:embed actfile-schema.json +var actfileSchema []byte + func main() { utils.ApplyLogLevel() @@ -23,5 +28,6 @@ func main() { return } + cmd.ActfileSchema = actfileSchema cmd.Execute() } From 4fb3cd036179730c79dc79d31d5d5ee1ab1cadf7 Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Mon, 16 Feb 2026 00:42:37 -0500 Subject: [PATCH 3/4] Add schema command to print JSON schema for .act files --- cmd/cmd_validate.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cmd/cmd_validate.go b/cmd/cmd_validate.go index 05a8f5f..552bdaf 100644 --- a/cmd/cmd_validate.go +++ b/cmd/cmd_validate.go @@ -150,6 +150,17 @@ func expandPath(path string) string { return os.ExpandEnv(path) } +var cmdSchema = &cobra.Command{ + Use: "schema", + Short: "Print the JSON schema for .act files.", + Long: `Prints the JSON schema used to validate ActionForge graph (.act) files.`, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + fmt.Println(string(ActfileSchema)) + }, +} + func init() { cmdRoot.AddCommand(cmdValidate) + cmdRoot.AddCommand(cmdSchema) } From 1f8af81b4940e01748f5df7ff27688446b8213c2 Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Mon, 16 Feb 2026 00:47:52 -0500 Subject: [PATCH 4/4] Update e2e test reference files --- tests_e2e/references/reference_app.sh_l10 | 1 + tests_e2e/references/reference_app.sh_l35 | 2 +- tests_e2e/references/reference_app.sh_l36 | 2 +- tests_e2e/references/reference_contexts_env.sh_l26 | 1 + tests_e2e/references/reference_validate.sh_l8 | 2 +- 5 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tests_e2e/references/reference_app.sh_l10 b/tests_e2e/references/reference_app.sh_l10 index 51b7da7..205ed22 100644 --- a/tests_e2e/references/reference_app.sh_l10 +++ b/tests_e2e/references/reference_app.sh_l10 @@ -7,6 +7,7 @@ Usage: Available Commands: completion Generate the autocompletion script for the specified shell help Help about any command + schema Print the JSON schema for .act files. validate Validate a graph file. version Print the version number of actrun diff --git a/tests_e2e/references/reference_app.sh_l35 b/tests_e2e/references/reference_app.sh_l35 index 1ca117a..4b0fc99 100644 --- a/tests_e2e/references/reference_app.sh_l35 +++ b/tests_e2e/references/reference_app.sh_l35 @@ -3,7 +3,7 @@ looking for value: 'graph_file' no value (is optional) found for: 'graph_file' Validating '[REDACTED]/missing-exec-connection1.act'... -❌ Validation failed with 1 error(s): +❌ Graph validation failed with 1 error(s): --- Error 1 --- error: diff --git a/tests_e2e/references/reference_app.sh_l36 b/tests_e2e/references/reference_app.sh_l36 index 58f5d5d..b63d043 100644 --- a/tests_e2e/references/reference_app.sh_l36 +++ b/tests_e2e/references/reference_app.sh_l36 @@ -3,7 +3,7 @@ looking for value: 'graph_file' no value (is optional) found for: 'graph_file' Validating '[REDACTED]/missing-exec-connection2.act'... -❌ Validation failed with 2 error(s): +❌ Graph validation failed with 2 error(s): --- Error 1 --- error: diff --git a/tests_e2e/references/reference_contexts_env.sh_l26 b/tests_e2e/references/reference_contexts_env.sh_l26 index 13b4eed..794f650 100644 --- a/tests_e2e/references/reference_contexts_env.sh_l26 +++ b/tests_e2e/references/reference_contexts_env.sh_l26 @@ -7,6 +7,7 @@ Usage: Available Commands: completion Generate the autocompletion script for the specified shell help Help about any command + schema Print the JSON schema for .act files. validate Validate a graph file. version Print the version number of actrun diff --git a/tests_e2e/references/reference_validate.sh_l8 b/tests_e2e/references/reference_validate.sh_l8 index 3cae4d2..c7352c3 100644 --- a/tests_e2e/references/reference_validate.sh_l8 +++ b/tests_e2e/references/reference_validate.sh_l8 @@ -4,7 +4,7 @@ looking for value: 'graph_file' evaluated to: 'validate.act' Validating 'validate.act'... -❌ Validation failed with 4 error(s): +❌ Graph validation failed with 4 error(s): --- Error 1 --- error: