diff --git a/assets/js/yaml/schema/workflow-spec-v2.json b/assets/js/yaml/schema/workflow-spec-v2.json index 5c6905e81d..6c863d9fdf 100644 --- a/assets/js/yaml/schema/workflow-spec-v2.json +++ b/assets/js/yaml/schema/workflow-spec-v2.json @@ -79,6 +79,7 @@ } }, "properties": { + "schema_version": { "type": "string" }, "id": { "type": "string" }, "name": { "type": ["string", "null"] }, "start": { "type": "string" }, diff --git a/assets/js/yaml/v2.ts b/assets/js/yaml/v2.ts index daf3d41956..3fd907b856 100644 --- a/assets/js/yaml/v2.ts +++ b/assets/js/yaml/v2.ts @@ -250,6 +250,7 @@ type CanonicalStep = CanonicalTriggerStep | CanonicalJobStep; interface CanonicalWorkflow { id: string; name: string; + schema_version: string; start?: string; steps: CanonicalStep[]; } @@ -344,6 +345,7 @@ const workflowStateToCanonical = (state: WorkflowState): CanonicalWorkflow => { return { id: hyphenate(state.name), name: state.name, + schema_version: '4.0', ...(start ? { start } : {}), // Trigger steps first, then job steps — matches Elixir's emit order. steps: [...triggerSteps, ...jobSteps], @@ -486,6 +488,9 @@ const RESERVED_YAML = new Set([ const needsQuoting = (s: string): boolean => { if (s === '') return true; if (RESERVED_YAML.has(s.toLowerCase())) return true; + // Quote strings that would otherwise parse as a YAML number on read + // (e.g. "4.0" must stay a string for schema_version). + if (/^-?(\d+\.?\d*|\.\d+)$/.test(s)) return true; // Mirrors `Lightning.Workflows.YamlFormat.V2.quote_if_needed/1`: // ^[A-Za-z0-9][A-Za-z0-9_\-@./> ]*[A-Za-z0-9]$ (and not reserved) return !/^[A-Za-z0-9][A-Za-z0-9_\-@./> ]*[A-Za-z0-9]$/.test(s); diff --git a/lib/lightning/workflows/yaml_format/v2.ex b/lib/lightning/workflows/yaml_format/v2.ex index 860210522f..10b7b91827 100644 --- a/lib/lightning/workflows/yaml_format/v2.ex +++ b/lib/lightning/workflows/yaml_format/v2.ex @@ -457,6 +457,7 @@ defmodule Lightning.Workflows.YamlFormat.V2 do [ emit_scalar_field("id", Map.get(workflow_canonical, :id)), emit_scalar_field("name", Map.get(workflow_canonical, :name)), + emit_scalar_field("schema_version", "4.0"), emit_scalar_field("start", Map.get(workflow_canonical, :start)), emit_steps(triggers ++ jobs) ] @@ -657,6 +658,11 @@ defmodule Lightning.Workflows.YamlFormat.V2 do value == "" -> "''" + # Quote strings that would otherwise parse as a YAML number on read + # (e.g. "4.0" must stay a string for schema_version). + Regex.match?(~r/^-?(\d+\.?\d*|\.\d+)$/, value) -> + "'" <> value <> "'" + Regex.match?(~r/^[a-zA-Z0-9][a-zA-Z0-9_\-@\.\/> |]*[a-zA-Z0-9]$/, value) and not yaml_reserved?(value) -> value @@ -772,6 +778,7 @@ defmodule Lightning.Workflows.YamlFormat.V2 do [ emit_top_scalar("id", Map.get(project_canonical, :id)), emit_top_scalar("name", Map.get(project_canonical, :name)), + emit_top_scalar("schema_version", "4.0"), emit_top_description(Map.get(project_canonical, :description)), emit_collections_array(Map.get(project_canonical, :collections, [])), emit_credentials_array(Map.get(project_canonical, :credentials, [])), diff --git a/test/fixtures/portability/v2/canonical_project.yaml b/test/fixtures/portability/v2/canonical_project.yaml index 5b1fd75a64..4cb588a895 100644 --- a/test/fixtures/portability/v2/canonical_project.yaml +++ b/test/fixtures/portability/v2/canonical_project.yaml @@ -1,5 +1,6 @@ id: a-test-project name: a-test-project +schema_version: '4.0' description: | This is only a test collections: diff --git a/test/fixtures/portability/v2/canonical_workflow.yaml b/test/fixtures/portability/v2/canonical_workflow.yaml index c635bfc898..7248bcc29d 100644 --- a/test/fixtures/portability/v2/canonical_workflow.yaml +++ b/test/fixtures/portability/v2/canonical_workflow.yaml @@ -1,5 +1,6 @@ id: canonical-workflow name: canonical workflow +schema_version: '4.0' start: cron steps: - id: cron