Merge pull request #3 from roeiba/main

Add CI code and logic to validate flows
This commit is contained in:
Enes Cingöz
2025-05-25 01:30:23 +01:00
committed by GitHub
8 changed files with 609 additions and 0 deletions

133
.github/workflows/validate-workflows.yml vendored Normal file
View File

@@ -0,0 +1,133 @@
name: Validate n8n Workflows
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
workflow_dispatch: # Allow manual triggering
jobs:
validate-workflows:
name: Validate n8n Workflows
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install dependencies
working-directory: ./lib
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -e .
- name: Run workflow validation
id: validate
working-directory: ./lib
run: |
# Run the validator on all JSON files in the repository
# This will fail if any workflow is invalid
echo "Validating all n8n workflows..."
if ! n8n-validate ..; then
echo "::error::One or more workflow validations failed"
exit 1
fi
echo "All workflows are valid!"
- name: Create visualization artifacts
if: always() # Run this step even if validation fails
working-directory: ./lib
run: |
echo "Creating visualizations for all workflows..."
mkdir -p ../workflow-visualizations
# Find all JSON files that might be n8n workflows
find .. -type f -name "*.json" -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/workflow-visualizations/*" | while read -r file; do
# Try to validate the file first
if n8n-validate "$file" 2>/dev/null; then
# If validation passes, create a visualization
echo "Creating visualization for $file"
filename=$(basename "$file" .json)
output_file="../workflow-visualizations/${filename}.png"
if ! n8n-visualize "$file" -o "$output_file" --no-show 2>/dev/null; then
echo "::warning::Failed to create visualization for $file"
fi
fi
done
# Count the number of visualizations created
VIS_COUNT=$(find ../workflow-visualizations -type f -name "*.png" | wc -l)
echo "Created $VIS_COUNT workflow visualizations"
# Set an output with the visualization count
echo "visualization_count=$VIS_COUNT" >> $GITHUB_OUTPUT
- name: Upload workflow visualizations
if: always() && steps.validate.outcome == 'success'
uses: actions/upload-artifact@v4
with:
name: workflow-visualizations
path: workflow-visualizations/
if-no-files-found: ignore
retention-days: 7
- name: Comment on PR with validation results
if: github.event_name == 'pull_request' && steps.validate.outcome == 'success'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const { execSync } = require('child_process');
// Get the list of workflow files that were validated
const workflowFiles = execSync('find .. -type f -name "*.json" -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/workflow-visualizations/*"')
.toString()
.split('\n')
.filter(Boolean);
// Count visualizations
let visCount = 0;
try {
visCount = fs.readdirSync('../workflow-visualizations').length;
} catch (e) {
// Directory might not exist if no visualizations were created
}
// Create a comment
const comment = `✅ All ${workflowFiles.length} n8n workflow files are valid!\n` +
`📊 ${visCount} workflow visualizations were generated and attached as artifacts.`;
// Add a comment to the PR
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.find(comment =>
comment.user.login === 'github-actions[bot]' &&
comment.body.includes('n8n workflow')
);
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: comment,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: comment,
});
}

View File

@@ -0,0 +1,170 @@
"""
Example script demonstrating how to use the n8n_utils visualization.
"""
import json
import os
import sys
from pathlib import Path
# Add the lib directory to the path so we can import n8n_utils
sys.path.insert(0, str(Path(__file__).parent.parent))
from n8n_utils.visualization import visualize_workflow
def create_sample_workflow():
"""Create a sample n8n workflow for demonstration."""
return {
"name": "Sample Workflow",
"nodes": [
{
"id": "1",
"name": "Start",
"type": "n8n-nodes-base.start",
"typeVersion": 1,
"position": [250, 300],
"parameters": {}
},
{
"id": "2",
"name": "HTTP Request",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 1,
"position": [450, 200],
"parameters": {
"url": "https://api.example.com/data",
"method": "GET"
}
},
{
"id": "3",
"name": "Process Data",
"type": "n8n-nodes-base.function",
"typeVersion": 1,
"position": [650, 300],
"parameters": {
"functionCode": "// Process the data here\nreturn items;"
}
},
{
"id": "4",
"name": "Condition",
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [850, 300],
"parameters": {
"conditions": {
"string": [
{
"value1": "={{ $json.someField }}",
"operation": "exists"
}
]
}
}
},
{
"id": "5",
"name": "Send Email",
"type": "n8n-nodes-base.emailSend",
"typeVersion": 1,
"position": [1050, 200],
"parameters": {
"to": "user@example.com",
"subject": "Processing Complete",
"text": "The data has been processed successfully."
}
},
{
"id": "6",
"name": "Log Error",
"type": "n8n-nodes-base.function",
"typeVersion": 1,
"position": [1050, 400],
"parameters": {
"functionCode": "console.log('Error processing data:', items);\nreturn items;"
}
}
],
"connections": {
"1": {
"main": [
[
{
"node": "2",
"type": "main",
"index": 0
}
]
]
},
"2": {
"main": [
[
{
"node": "3",
"type": "main",
"index": 0
}
]
]
},
"3": {
"main": [
[
{
"node": "4",
"type": "main",
"index": 0
}
]
]
},
"4": {
"main": [
[
{
"node": "5",
"type": "main",
"index": 0
}
]
]
},
"4-1": {
"main": [
[
{
"node": "6",
"type": "main",
"index": 0
}
]
]
}
}
}
def main():
"""Run the example."""
# Create output directory if it doesn't exist
output_dir = Path(__file__).parent / "output"
output_dir.mkdir(exist_ok=True)
# Create a sample workflow
workflow = create_sample_workflow()
# Save the workflow as JSON
workflow_file = output_dir / "sample_workflow.json"
with open(workflow_file, 'w', encoding='utf-8') as f:
json.dump(workflow, f, indent=2)
print(f"Created sample workflow at: {workflow_file}")
# Visualize the workflow
output_image = output_dir / "workflow_visualization.png"
from n8n_utils.visualization.visualizer import visualize_workflow
visualize_workflow(workflow, output_file=str(output_image), show=True)
print(f"Workflow visualization saved to: {output_image}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,8 @@
"""
n8n Utils
A collection of utilities for working with n8n workflows,
including validation and visualization tools.
"""
__version__ = "0.1.0"

View File

@@ -0,0 +1,15 @@
"""
n8n CI Utilities
This package provides tools for CI/CD integration with n8n workflows,
including validation and testing utilities.
"""
from .validator import validate_workflow, validate_workflow_file, validate_all_workflows, ValidationError
__all__ = [
'validate_workflow',
'validate_workflow_file',
'validate_all_workflows',
'ValidationError',
]

View File

@@ -0,0 +1,158 @@
"""
n8n Workflow Validator
This module provides functionality to validate n8n workflow JSON files
against a schema to ensure they are properly formatted.
"""
import json
import os
import sys
from pathlib import Path
from typing import Dict, List, Optional, Union
import jsonschema
from jsonschema import validate
# Define the n8n workflow JSON schema
N8N_WORKFLOW_SCHEMA = {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["nodes", "connections"],
"properties": {
"name": {"type": "string"},
"nodes": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "name", "type", "typeVersion", "position", "parameters"],
"properties": {
"id": {"type": "string"},
"name": {"type": "string"},
"type": {"type": "string"},
"typeVersion": {"type": ["number", "string"]},
"position": {
"type": "array",
"items": {"type": "number"},
"minItems": 2,
"maxItems": 2
},
"parameters": {"type": "object"},
},
},
},
"connections": {"type": "object"},
},
}
class ValidationError(Exception):
"""Custom exception for validation errors."""
pass
def validate_workflow(workflow_data: Dict) -> List[str]:
"""
Validate an n8n workflow against the schema.
Args:
workflow_data: The parsed JSON data of the workflow
Returns:
List of error messages, empty if valid
"""
try:
validate(instance=workflow_data, schema=N8N_WORKFLOW_SCHEMA)
return []
except jsonschema.exceptions.ValidationError as e:
return [f"Validation error: {e.message} at {'.'.join(map(str, e.path))}"]
def validate_workflow_file(file_path: Union[str, Path]) -> List[str]:
"""
Validate an n8n workflow file.
Args:
file_path: Path to the JSON file to validate
Returns:
List of error messages, empty if valid
"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
try:
workflow_data = json.load(f)
except json.JSONDecodeError as e:
return [f"Invalid JSON in {file_path}: {str(e)}"]
return validate_workflow(workflow_data)
except Exception as e:
return [f"Error reading {file_path}: {str(e)}"]
def find_workflow_files(directory: Union[str, Path]) -> List[Path]:
"""
Recursively find all JSON files in a directory that might be n8n workflows.
Args:
directory: Directory to search in
Returns:
List of Path objects to potential workflow files
"""
directory = Path(directory)
return list(directory.glob("**/*.json"))
def validate_all_workflows(directory: Union[str, Path]) -> Dict[str, List[str]]:
"""
Validate all JSON files in a directory and its subdirectories.
Args:
directory: Directory to search for workflow files
Returns:
Dictionary mapping file paths to lists of error messages
"""
workflow_files = find_workflow_files(directory)
results = {}
for file_path in workflow_files:
errors = validate_workflow_file(file_path)
if errors:
results[str(file_path)] = errors
return results
def main():
"""Command-line interface for the validator."""
import argparse
parser = argparse.ArgumentParser(description="Validate n8n workflow files.")
parser.add_argument(
"directory",
nargs="?",
default=".",
help="Directory containing n8n workflow files (default: current directory)",
)
args = parser.parse_args()
directory = Path(args.directory).resolve()
if not directory.exists():
print(f"Error: Directory '{directory}' does not exist")
sys.exit(1)
print(f"Validating n8n workflows in: {directory}")
results = validate_all_workflows(directory)
if not results:
print("✅ All workflow files are valid!")
sys.exit(0)
# Print errors
error_count = 0
for file_path, errors in results.items():
print(f"\n{file_path}:")
for error in errors:
print(f" - {error}")
error_count += 1
print(f"\nFound {error_count} error(s) in {len(results)} file(s)")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,79 @@
"""Tests for n8n workflow validation."""
import json
import os
import tempfile
import unittest
from pathlib import Path
from n8n_utils.ci.validator import validate_workflow, ValidationError
class TestWorkflowValidation(unittest.TestCase):
"""Test cases for workflow validation."""
def setUp(self):
"""Set up test fixtures."""
self.valid_workflow = {
"name": "Test Workflow",
"nodes": [
{
"id": "1",
"name": "Start",
"type": "n8n-nodes-base.start",
"typeVersion": 1,
"position": [250, 300],
"parameters": {}
},
{
"id": "2",
"name": "HTTP Request",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 1,
"position": [450, 300],
"parameters": {
"url": "https://example.com",
"method": "GET"
}
}
],
"connections": {
"1": {
"main": [
[
{
"node": "2",
"type": "main",
"index": 0
}
]
]
}
}
}
def test_valid_workflow(self):
"""Test validation of a valid workflow."""
errors = validate_workflow(self.valid_workflow)
self.assertEqual(len(errors), 0)
def test_missing_required_field(self):
"""Test validation of a workflow with a missing required field."""
# Remove a required field
invalid_workflow = self.valid_workflow.copy()
del invalid_workflow["nodes"][0]["id"]
errors = validate_workflow(invalid_workflow)
self.assertGreater(len(errors), 0)
self.assertIn("id", errors[0])
def test_invalid_node_structure(self):
"""Test validation of a workflow with invalid node structure."""
invalid_workflow = self.valid_workflow.copy()
# Make position an invalid type
invalid_workflow["nodes"][0]["position"] = "not an array"
errors = validate_workflow(invalid_workflow)
self.assertGreater(len(errors), 0)
self.assertIn("position", errors[0])
if __name__ == "__main__":
unittest.main()

7
lib/requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
jsonschema>=4.0.0
networkx>=2.6.3
matplotlib>=3.4.3
pytest>=7.0.0
pytest-cov>=3.0.0
click>=8.0.0
python-dotenv>=0.19.0

39
lib/setup.py Normal file
View File

@@ -0,0 +1,39 @@
from setuptools import setup, find_packages
import os
# Get the long description from the README file
here = os.path.abspath(os.path.dirname(__file__))
with open(os.path.join(here, '..', 'README.md'), encoding='utf-8') as f:
long_description = f.read()
setup(
name="n8n_utils",
version="0.1.0",
packages=find_packages(where='.'),
package_dir={'': '.'},
install_requires=[
"jsonschema>=4.0.0",
"networkx>=2.6.3",
"matplotlib>=3.4.3",
"click>=8.0.0",
"python-dotenv>=0.19.0",
],
entry_points={
'console_scripts': [
'n8n-validate=n8n_utils.ci.validator:main',
'n8n-visualize=n8n_utils.visualization.visualizer:main',
],
},
python_requires='>=3.8',
author="Your Name",
author_email="your.email@example.com",
description="Utilities for n8n workflow validation and visualization",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/yourusername/n8n-free-templates",
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
],
)