From 8f49898c1760db200505e3fe0ff416ffe10f4886 Mon Sep 17 00:00:00 2001 From: "Bernhard M. Wiedemann" Date: Fri, 17 Jul 2026 23:42:36 +0200 Subject: [PATCH 1/2] epub: Honor SOURCE_DATE_EPOCH for dcterms:modified and derive dc:identifier The EPUB content.opf embedded the build time as dcterms:modified and a random UUID as dc:identifier, making the .epub files differ between two builds of the same docs. Derive the identifier from the project name and version instead of random data, and use SOURCE_DATE_EPOCH (https://reproducible-builds.org/docs/source-date-epoch/) as the modification time when it is set. Co-Authored-By: Claude Fable 5 --- lib/ex_doc/formatter/epub.ex | 42 ++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/lib/ex_doc/formatter/epub.ex b/lib/ex_doc/formatter/epub.ex index 64cafbf54..d308836af 100644 --- a/lib/ex_doc/formatter/epub.ex +++ b/lib/ex_doc/formatter/epub.ex @@ -59,7 +59,7 @@ defmodule ExDoc.Formatter.EPUB do end defp generate_content(config, modules, tasks, extras, static_files) do - uuid = "urn:uuid:#{uuid4()}" + uuid = "urn:uuid:#{uuid(config)}" datetime = format_datetime() static_files = @@ -137,7 +137,22 @@ defmodule ExDoc.Formatter.EPUB do # Helper to format Erlang datetime tuple defp format_datetime do - {{year, month, day}, {hour, min, sec}} = :calendar.universal_time() + # Use SOURCE_DATE_EPOCH instead of the current time when set, for + # reproducible builds + # (https://reproducible-builds.org/docs/source-date-epoch/). + time = + case System.get_env("SOURCE_DATE_EPOCH") do + nil -> + :calendar.universal_time() + + epoch -> + case Integer.parse(epoch) do + {seconds, ""} -> :calendar.system_time_to_universal_time(seconds, :second) + _ -> :calendar.universal_time() + end + end + + {{year, month, day}, {hour, min, sec}} = time list = [year, month, day, hour, min, sec] "~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ" @@ -145,21 +160,12 @@ defmodule ExDoc.Formatter.EPUB do |> IO.iodata_to_binary() end - @two_power_16 65536 - @two_power_32 4_294_967_296 - @two_power_48 281_474_976_710_656 - - defp uuid4 do - Enum.map_join( - [ - <<:rand.uniform(@two_power_32) - 1::32>>, - <<:rand.uniform(@two_power_16) - 1::16>>, - <<:rand.uniform(@two_power_16) - 1::16>>, - <<:rand.uniform(@two_power_16) - 1::16>>, - <<:rand.uniform(@two_power_48) - 1::48>> - ], - <<45>>, - &Base.encode16(&1, case: :lower) - ) + # For reproducible builds, derive the identifier from the project name + # and version instead of random data + defp uuid(config) do + <> = :erlang.md5("#{config.project} #{config.version}") + + Enum.map_join([a, b, c, d, e], <<45>>, &Base.encode16(&1, case: :lower)) end end From 1e99af417e68cbcc5a05bf9fa0011894450945b1 Mon Sep 17 00:00:00 2001 From: "Bernhard M. Wiedemann" Date: Mon, 27 Jul 2026 22:56:09 +0200 Subject: [PATCH 2/2] Apply suggestion from @josevalim --- lib/ex_doc/formatter/epub.ex | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/lib/ex_doc/formatter/epub.ex b/lib/ex_doc/formatter/epub.ex index d308836af..5fdd9257b 100644 --- a/lib/ex_doc/formatter/epub.ex +++ b/lib/ex_doc/formatter/epub.ex @@ -140,19 +140,14 @@ defmodule ExDoc.Formatter.EPUB do # Use SOURCE_DATE_EPOCH instead of the current time when set, for # reproducible builds # (https://reproducible-builds.org/docs/source-date-epoch/). - time = - case System.get_env("SOURCE_DATE_EPOCH") do - nil -> - :calendar.universal_time() - - epoch -> - case Integer.parse(epoch) do - {seconds, ""} -> :calendar.system_time_to_universal_time(seconds, :second) - _ -> :calendar.universal_time() - end + {{year, month, day}, {hour, min, sec}} = + with epoch when is_binary(epoch) <- System.get_env("SOURCE_DATE_EPOCH"), + {seconds, ""} <- Integer.parse(epoch) do + :calendar.system_time_to_universal_time(seconds, :second) + else + _ -> :calendar.universal_time() end - {{year, month, day}, {hour, min, sec}} = time list = [year, month, day, hour, min, sec] "~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ"