From 35f7211d4de40d77da62b417e429236e809a1300 Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Thu, 14 May 2026 14:20:09 +0100 Subject: [PATCH 1/4] add mix task to generate version hash --- lib/mix/tasks/gen_workflow_hash.ex | 61 ++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 lib/mix/tasks/gen_workflow_hash.ex diff --git a/lib/mix/tasks/gen_workflow_hash.ex b/lib/mix/tasks/gen_workflow_hash.ex new file mode 100644 index 0000000000..8e4abe68bd --- /dev/null +++ b/lib/mix/tasks/gen_workflow_hash.ex @@ -0,0 +1,61 @@ +defmodule Mix.Tasks.Lightning.GenWorkflowHash do + @shortdoc "Generate the version hash for a workflow" + + @moduledoc """ + Generates a deterministic version hash for an existing workflow. + + ## Usage + + mix lightning.gen_workflow_hash WORKFLOW_UUID + + ## Arguments + + * `WORKFLOW_UUID` - The UUID of the workflow to hash + + ## Examples + + mix lightning.gen_workflow_hash 550e8400-e29b-41d4-a716-446655440000 + """ + use Mix.Task + + require Logger + + alias Lightning.Workflows + alias Lightning.WorkflowVersions + + @impl Mix.Task + def run(args) do + case args do + [workflow_id] -> + start_repo() + print_hash(workflow_id) + + _ -> + Mix.raise(""" + Expected exactly 1 argument: WORKFLOW_UUID + + Usage: + mix lightning.gen_workflow_hash WORKFLOW_UUID + """) + end + end + + defp start_repo do + Logger.configure(level: :error) + Mix.Task.run("app.config") + {:ok, _} = Application.ensure_all_started(:ecto_sql) + {:ok, _} = Lightning.Repo.start_link(pool_size: 1) + end + + defp print_hash(workflow_id) do + case Workflows.get_workflow(workflow_id) do + nil -> + Mix.raise("Workflow #{workflow_id} not found") + + workflow -> + workflow + |> WorkflowVersions.generate_hash() + |> IO.puts() + end + end +end From 40d6b4379a06b45f9094fc500ae02aa2c82e65ef Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Thu, 14 May 2026 14:40:18 +0100 Subject: [PATCH 2/4] optional flag to skip the hash --- lib/lightning/workflow_versions.ex | 29 +++++++++++++++++++-------- lib/mix/tasks/gen_workflow_hash.ex | 32 +++++++++++++++++++++--------- 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/lib/lightning/workflow_versions.ex b/lib/lightning/workflow_versions.ex index 011088f56c..6310eaba51 100644 --- a/lib/lightning/workflow_versions.ex +++ b/lib/lightning/workflow_versions.ex @@ -217,25 +217,34 @@ defmodule Lightning.WorkflowVersions do ## Parameters * `workflow` — the workflow struct to hash + * `opts` — options: + * `hash: false` — return the joined pre-hash string instead of hashing it. + Useful for debugging what's fed into the hash. Defaults to `true`. ## Returns - * A 12-character lowercase hex string + * When `hash: true` (default): a 12-character lowercase hex string + * When `hash: false`: the joined pre-hash string ## Examples iex> WorkflowVersions.generate_hash(workflow) "a1b2c3d4e5f6" + + iex> WorkflowVersions.generate_hash(workflow, hash: false) + "My Workflow{...}webhook..." """ - @spec generate_hash(Workflow.t() | map()) :: binary() - def generate_hash(%Workflow{} = workflow) do + @spec generate_hash(Workflow.t() | map(), keyword()) :: binary() + def generate_hash(workflow, opts \\ []) + + def generate_hash(%Workflow{} = workflow, opts) do workflow = Repo.preload(workflow, [:jobs, :edges, :triggers]) workflow |> Map.from_struct() - |> generate_hash() + |> generate_hash(opts) end - def generate_hash(%{} = workflow) do + def generate_hash(%{} = workflow, opts) do workflow_keys = [:name, :positions] job_keys = [ @@ -320,9 +329,13 @@ defmodule Lightning.WorkflowVersions do edges_hash_list ]) - :crypto.hash(:sha256, joined_data) - |> Base.encode16(case: :lower) - |> binary_part(0, 12) + if Keyword.get(opts, :hash, true) do + :crypto.hash(:sha256, joined_data) + |> Base.encode16(case: :lower) + |> binary_part(0, 12) + else + joined_data + end end defp serialize_value(val) when is_map(val) do diff --git a/lib/mix/tasks/gen_workflow_hash.ex b/lib/mix/tasks/gen_workflow_hash.ex index 8e4abe68bd..2cd02ddc1d 100644 --- a/lib/mix/tasks/gen_workflow_hash.ex +++ b/lib/mix/tasks/gen_workflow_hash.ex @@ -6,15 +6,21 @@ defmodule Mix.Tasks.Lightning.GenWorkflowHash do ## Usage - mix lightning.gen_workflow_hash WORKFLOW_UUID + mix lightning.gen_workflow_hash WORKFLOW_UUID [--no-hash] ## Arguments * `WORKFLOW_UUID` - The UUID of the workflow to hash + ## Options + + * `--no-hash` - Print the joined pre-hash string instead of the hash. + Useful for debugging what's fed into the hash. + ## Examples mix lightning.gen_workflow_hash 550e8400-e29b-41d4-a716-446655440000 + mix lightning.gen_workflow_hash 550e8400-e29b-41d4-a716-446655440000 --no-hash """ use Mix.Task @@ -25,18 +31,26 @@ defmodule Mix.Tasks.Lightning.GenWorkflowHash do @impl Mix.Task def run(args) do - case args do - [workflow_id] -> - start_repo() - print_hash(workflow_id) + {opts, positional, invalid} = + OptionParser.parse(args, strict: [hash: :boolean]) - _ -> + cond do + length(invalid) > 0 -> + invalid_opts = Enum.map_join(invalid, ", ", fn {opt, _} -> opt end) + Mix.raise("Unknown option(s): #{invalid_opts}") + + length(positional) != 1 -> Mix.raise(""" Expected exactly 1 argument: WORKFLOW_UUID Usage: - mix lightning.gen_workflow_hash WORKFLOW_UUID + mix lightning.gen_workflow_hash WORKFLOW_UUID [--no-hash] """) + + true -> + [workflow_id] = positional + start_repo() + print_hash(workflow_id, opts) end end @@ -47,14 +61,14 @@ defmodule Mix.Tasks.Lightning.GenWorkflowHash do {:ok, _} = Lightning.Repo.start_link(pool_size: 1) end - defp print_hash(workflow_id) do + defp print_hash(workflow_id, opts) do case Workflows.get_workflow(workflow_id) do nil -> Mix.raise("Workflow #{workflow_id} not found") workflow -> workflow - |> WorkflowVersions.generate_hash() + |> WorkflowVersions.generate_hash(opts) |> IO.puts() end end From aec8c2652e3b68f91446099d1f4eb5b0b07f47f9 Mon Sep 17 00:00:00 2001 From: Frank Midigo Date: Tue, 30 Jun 2026 08:47:45 +0300 Subject: [PATCH 3/4] Split generate_hash into generate_hash and canonical_form Replace the generate_hash/2 `hash: false` option with a dedicated canonical_form/1 function that returns the pre-hash joined string. A function named generate_hash that is asked not to hash read as contradictory; the two concerns are now separate public functions. Update the gen_workflow_hash task to route --no-hash through canonical_form/1, and make its repo bootstrap tolerant of an already-running repo so the task can be tested. Add task coverage. --- lib/lightning/workflow_versions.ex | 76 ++++++++++++----------- lib/mix/tasks/gen_workflow_hash.ex | 13 +++- test/mix/tasks/gen_workflow_hash_test.exs | 56 +++++++++++++++++ 3 files changed, 107 insertions(+), 38 deletions(-) create mode 100644 test/mix/tasks/gen_workflow_hash_test.exs diff --git a/lib/lightning/workflow_versions.ex b/lib/lightning/workflow_versions.ex index 6310eaba51..c2181e451d 100644 --- a/lib/lightning/workflow_versions.ex +++ b/lib/lightning/workflow_versions.ex @@ -203,6 +203,33 @@ defmodule Lightning.WorkflowVersions do @doc """ Generates a deterministic hash for a workflow based on its structure. + Hashes the string produced by `canonical_form/1` with SHA-256 and truncates + the result to 12 lowercase hex characters. + + ## Parameters + * `workflow` — the workflow struct (or equivalent map) to hash + + ## Returns + * a 12-character lowercase hex string + + ## Examples + + iex> WorkflowVersions.generate_hash(workflow) + "a1b2c3d4e5f6" + """ + @spec generate_hash(Workflow.t() | map()) :: binary() + def generate_hash(workflow) do + :crypto.hash(:sha256, canonical_form(workflow)) + |> Base.encode16(case: :lower) + |> binary_part(0, 12) + end + + @doc """ + Builds the deterministic canonical string for a workflow — the exact input + that `generate_hash/1` digests. + + Useful for debugging what's fed into the hash. + Algorithm: - Create a list - Add the workflow name to the start of the list @@ -212,39 +239,27 @@ defmodule Lightning.WorkflowVersions do - Add only the field VALUES to the list (keys are excluded) - Numeric values (e.g., positions) are rounded up to integers - Join the list into a string, no separator - - Hash the string with SHA 256 - - Truncate the resulting string to 12 characters ## Parameters - * `workflow` — the workflow struct to hash - * `opts` — options: - * `hash: false` — return the joined pre-hash string instead of hashing it. - Useful for debugging what's fed into the hash. Defaults to `true`. + * `workflow` — the workflow struct (or equivalent map) to serialize ## Returns - * When `hash: true` (default): a 12-character lowercase hex string - * When `hash: false`: the joined pre-hash string + * the joined canonical string ## Examples - iex> WorkflowVersions.generate_hash(workflow) - "a1b2c3d4e5f6" - - iex> WorkflowVersions.generate_hash(workflow, hash: false) + iex> WorkflowVersions.canonical_form(workflow) "My Workflow{...}webhook..." """ - @spec generate_hash(Workflow.t() | map(), keyword()) :: binary() - def generate_hash(workflow, opts \\ []) - - def generate_hash(%Workflow{} = workflow, opts) do - workflow = Repo.preload(workflow, [:jobs, :edges, :triggers]) - + @spec canonical_form(Workflow.t() | map()) :: binary() + def canonical_form(%Workflow{} = workflow) do workflow + |> Repo.preload([:jobs, :edges, :triggers]) |> Map.from_struct() - |> generate_hash(opts) + |> canonical_form() end - def generate_hash(%{} = workflow, opts) do + def canonical_form(%{} = workflow) do workflow_keys = [:name, :positions] job_keys = [ @@ -321,21 +336,12 @@ defmodule Lightning.WorkflowVersions do acc ++ hash_list end) - joined_data = - Enum.join([ - workflow_hash_list, - triggers_hash_list, - jobs_hash_list, - edges_hash_list - ]) - - if Keyword.get(opts, :hash, true) do - :crypto.hash(:sha256, joined_data) - |> Base.encode16(case: :lower) - |> binary_part(0, 12) - else - joined_data - end + Enum.join([ + workflow_hash_list, + triggers_hash_list, + jobs_hash_list, + edges_hash_list + ]) end defp serialize_value(val) when is_map(val) do diff --git a/lib/mix/tasks/gen_workflow_hash.ex b/lib/mix/tasks/gen_workflow_hash.ex index 2cd02ddc1d..a768edc765 100644 --- a/lib/mix/tasks/gen_workflow_hash.ex +++ b/lib/mix/tasks/gen_workflow_hash.ex @@ -58,7 +58,11 @@ defmodule Mix.Tasks.Lightning.GenWorkflowHash do Logger.configure(level: :error) Mix.Task.run("app.config") {:ok, _} = Application.ensure_all_started(:ecto_sql) - {:ok, _} = Lightning.Repo.start_link(pool_size: 1) + + case Lightning.Repo.start_link(pool_size: 1) do + {:ok, _pid} -> :ok + {:error, {:already_started, _pid}} -> :ok + end end defp print_hash(workflow_id, opts) do @@ -67,8 +71,11 @@ defmodule Mix.Tasks.Lightning.GenWorkflowHash do Mix.raise("Workflow #{workflow_id} not found") workflow -> - workflow - |> WorkflowVersions.generate_hash(opts) + if Keyword.get(opts, :hash, true) do + WorkflowVersions.generate_hash(workflow) + else + WorkflowVersions.canonical_form(workflow) + end |> IO.puts() end end diff --git a/test/mix/tasks/gen_workflow_hash_test.exs b/test/mix/tasks/gen_workflow_hash_test.exs new file mode 100644 index 0000000000..65bd87c31f --- /dev/null +++ b/test/mix/tasks/gen_workflow_hash_test.exs @@ -0,0 +1,56 @@ +defmodule Mix.Tasks.Lightning.GenWorkflowHashTest do + use Lightning.DataCase + + import ExUnit.CaptureIO + + alias Lightning.WorkflowVersions + alias Mix.Tasks.Lightning.GenWorkflowHash + + defp run(args) do + capture_io(fn -> GenWorkflowHash.run(args) end) |> String.trim() + end + + describe "run/1" do + test "prints the 12-char hash for an existing workflow" do + workflow = build_workflow() + + output = run([workflow.id]) + + assert output == WorkflowVersions.generate_hash(workflow) + assert String.match?(output, ~r/^[a-f0-9]{12}$/) + end + + test "with --no-hash prints the canonical pre-hash string" do + workflow = build_workflow() + + output = run([workflow.id, "--no-hash"]) + + assert output == WorkflowVersions.canonical_form(workflow) + # The canonical form is the un-digested input, so it is not a bare hash. + refute String.match?(output, ~r/^[a-f0-9]{12}$/) + end + + test "raises when the workflow does not exist" do + id = Ecto.UUID.generate() + + assert_raise Mix.Error, "Workflow #{id} not found", fn -> + run([id]) + end + end + end + + defp build_workflow do + workflow = insert(:workflow, name: "Hashable Workflow") + trigger = insert(:trigger, workflow: workflow, type: :webhook) + job = insert(:job, workflow: workflow, name: "Process") + + insert(:edge, + workflow: workflow, + source_trigger: trigger, + target_job: job, + condition_type: :always + ) + + Repo.preload(workflow, [:triggers, :jobs, :edges]) + end +end From 6e054d1b7faddf0180277c609c9f7eb1619236b5 Mon Sep 17 00:00:00 2001 From: Frank Midigo Date: Tue, 30 Jun 2026 09:48:12 +0300 Subject: [PATCH 4/4] make credo happy --- lib/mix/tasks/gen_workflow_hash.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/mix/tasks/gen_workflow_hash.ex b/lib/mix/tasks/gen_workflow_hash.ex index a768edc765..0cef14a467 100644 --- a/lib/mix/tasks/gen_workflow_hash.ex +++ b/lib/mix/tasks/gen_workflow_hash.ex @@ -24,11 +24,11 @@ defmodule Mix.Tasks.Lightning.GenWorkflowHash do """ use Mix.Task - require Logger - alias Lightning.Workflows alias Lightning.WorkflowVersions + require Logger + @impl Mix.Task def run(args) do {opts, positional, invalid} =