diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml index 167ec8ef..d6207139 100644 --- a/.forgejo/workflows/build.yaml +++ b/.forgejo/workflows/build.yaml @@ -41,6 +41,9 @@ jobs: run: | IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" TAG="${{ steps.tag.outputs.tag }}" - docker build -t "${IMAGE}:${TAG}" -t "${IMAGE}:latest" . + BUILD_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + docker build \ + --build-arg BUILD_TIMESTAMP="${BUILD_TIMESTAMP}" \ + -t "${IMAGE}:${TAG}" -t "${IMAGE}:latest" . docker push "${IMAGE}:${TAG}" docker push "${IMAGE}:latest" diff --git a/Dockerfile b/Dockerfile index e64d8e63..a7000f5e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -137,6 +137,11 @@ RUN mix release # ---- Final runtime stage ---- FROM ${RUNNER_IMAGE} AS final +# Build timestamp baked in by CI (--build-arg BUILD_TIMESTAMP=...). +# Only consumed in this final stage, so it never busts the earlier +# compile cache. Read at runtime by Microwaveprop.Application.build_timestamp/0. +ARG BUILD_TIMESTAMP=unknown + RUN apt-get update \ && apt-get install -y --no-install-recommends \ libstdc++6 openssl libncurses6 locales ca-certificates snmp \ @@ -162,6 +167,9 @@ RUN ldconfig COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/microwaveprop ./ +RUN echo "${BUILD_TIMESTAMP}" > /app/BUILD_TIMESTAMP \ + && chown nobody /app/BUILD_TIMESTAMP + USER nobody CMD ["/app/bin/server"] diff --git a/lib/microwaveprop/application.ex b/lib/microwaveprop/application.ex index 1f5beee5..0f386597 100644 --- a/lib/microwaveprop/application.ex +++ b/lib/microwaveprop/application.ex @@ -5,6 +5,8 @@ defmodule Microwaveprop.Application do use Application + @build_timestamp DateTime.utc_now() + @impl true def start(_type, _args) do topologies = Application.get_env(:libcluster, :topologies, []) @@ -53,4 +55,51 @@ defmodule Microwaveprop.Application do MicrowavepropWeb.Endpoint.config_change(changed, removed) :ok end + + @doc """ + Returns the deployment (image build) timestamp. + + Sources, in priority order: + + 1. The `/app/BUILD_TIMESTAMP` file baked into the Docker image at build + time (the CI pipeline passes `--build-arg BUILD_TIMESTAMP=...` in the + final stage, so this value is fresh on every image and is not affected + by earlier-stage layer caching). The path is overridable via + `config :microwaveprop, :build_timestamp_file` for tests. + 2. The `DEPLOY_TIMESTAMP` environment variable, for environments that set + it imperatively (e.g. `kubectl set env`). + 3. The compile-time fallback captured when this module was compiled. + + All accepted values are ISO 8601, e.g. `2026-04-17T18:30:00Z`. + """ + @spec build_timestamp() :: DateTime.t() + def build_timestamp do + with :error <- from_file(), + :error <- from_env() do + @build_timestamp + end + end + + defp from_file do + path = Application.get_env(:microwaveprop, :build_timestamp_file, "/app/BUILD_TIMESTAMP") + + case File.read(path) do + {:ok, contents} -> parse_iso8601(contents) + {:error, _} -> :error + end + end + + defp from_env do + case System.get_env("DEPLOY_TIMESTAMP") do + nil -> :error + string -> parse_iso8601(string) + end + end + + defp parse_iso8601(string) do + case string |> String.trim() |> DateTime.from_iso8601() do + {:ok, dt, _offset} -> dt + {:error, _} -> :error + end + end end diff --git a/lib/microwaveprop_web/components/layouts.ex b/lib/microwaveprop_web/components/layouts.ex index 6f239aac..324aa820 100644 --- a/lib/microwaveprop_web/components/layouts.ex +++ b/lib/microwaveprop_web/components/layouts.ex @@ -80,7 +80,8 @@ defmodule MicrowavepropWeb.Layouts do <% end %> -
+
+ <.deploy_stamp /> <.theme_toggle />
@@ -158,6 +159,38 @@ defmodule MicrowavepropWeb.Layouts do """ end + @doc """ + Shows when the running release was deployed, as a compact relative timestamp + with the full UTC time in the tooltip. + """ + def deploy_stamp(assigns) do + ts = Microwaveprop.Application.build_timestamp() + iso = Calendar.strftime(ts, "%Y-%m-%d %H:%M UTC") + assigns = assign(assigns, build_time: ts, iso: iso, ago: format_time_ago(ts)) + + ~H""" + + """ + end + + defp format_time_ago(%DateTime{} = dt) do + diff = max(DateTime.diff(DateTime.utc_now(), dt, :second), 0) + + cond do + diff < 60 -> "just now" + diff < 3600 -> "#{div(diff, 60)}m ago" + diff < 86_400 -> "#{div(diff, 3600)}h ago" + diff < 2_592_000 -> "#{div(diff, 86_400)}d ago" + diff < 31_536_000 -> "#{div(diff, 2_592_000)}mo ago" + true -> "#{div(diff, 31_536_000)}y ago" + end + end + @doc """ Provides dark vs light theme toggle based on themes defined in app.css. diff --git a/test/microwaveprop/application_test.exs b/test/microwaveprop/application_test.exs new file mode 100644 index 00000000..c4a57e9f --- /dev/null +++ b/test/microwaveprop/application_test.exs @@ -0,0 +1,61 @@ +defmodule Microwaveprop.ApplicationTest do + use ExUnit.Case, async: false + + alias Microwaveprop.Application, as: App + + describe "build_timestamp/0" do + setup do + original_env = System.get_env("DEPLOY_TIMESTAMP") + original_file = Application.get_env(:microwaveprop, :build_timestamp_file) + + tmp = Path.join(System.tmp_dir!(), "build_timestamp_#{System.unique_integer([:positive])}") + + on_exit(fn -> + if original_env do + System.put_env("DEPLOY_TIMESTAMP", original_env) + else + System.delete_env("DEPLOY_TIMESTAMP") + end + + if original_file do + Application.put_env(:microwaveprop, :build_timestamp_file, original_file) + else + Application.delete_env(:microwaveprop, :build_timestamp_file) + end + + File.rm(tmp) + end) + + System.delete_env("DEPLOY_TIMESTAMP") + Application.put_env(:microwaveprop, :build_timestamp_file, tmp) + + {:ok, tmp: tmp} + end + + test "returns a DateTime when no sources are present" do + assert %DateTime{} = App.build_timestamp() + end + + test "reads ISO8601 timestamp from the build-timestamp file", %{tmp: tmp} do + File.write!(tmp, "2026-04-15T12:30:00Z\n") + assert App.build_timestamp() == ~U[2026-04-15 12:30:00Z] + end + + test "file takes precedence over DEPLOY_TIMESTAMP env", %{tmp: tmp} do + File.write!(tmp, "2026-04-15T12:30:00Z") + System.put_env("DEPLOY_TIMESTAMP", "2026-01-01T00:00:00Z") + assert App.build_timestamp() == ~U[2026-04-15 12:30:00Z] + end + + test "falls back to DEPLOY_TIMESTAMP env when file is absent" do + System.put_env("DEPLOY_TIMESTAMP", "2026-02-02T02:02:02Z") + assert App.build_timestamp() == ~U[2026-02-02 02:02:02Z] + end + + test "falls back to compile time when both are malformed", %{tmp: tmp} do + File.write!(tmp, "not-a-timestamp") + System.put_env("DEPLOY_TIMESTAMP", "also-garbage") + assert %DateTime{} = App.build_timestamp() + end + end +end