Packages

phoenix_kit

1.7.143
1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

221 Versions

Jump to

Compare versions

20 files changed
+524 additions
-143 deletions
unknownCHANGELOG.md
File is too large to be displayed (100 KB limit).
  @@ -1,6 +1,6 @@
1 1 {<<"links">>,[{<<"GitHub">>,<<"https://github.com/BeamLabEU/phoenix_kit">>}]}.
2 2 {<<"name">>,<<"phoenix_kit">>}.
3 - {<<"version">>,<<"1.7.138">>}.
3 + {<<"version">>,<<"1.7.143">>}.
4 4 {<<"description">>,
5 5 <<65,32,102,111,117,110,100,97,116,105,111,110,32,102,111,114,32,98,117,105,
6 6 108,100,105,110,103,32,69,108,105,120,105,114,32,80,104,111,101,110,105,
  @@ -600,6 +600,7 @@
600 600 <<"lib/mix/tasks/phoenix_kit.doctor.ex">>,
601 601 <<"lib/mix/tasks/phoenix_kit_templates.ex">>,
602 602 <<"lib/mix/tasks/phoenix_kit.gen.user.dashboard.ex">>,
603 + <<"lib/mix/tasks/phoenix_kit.release_check.ex">>,
603 604 <<"lib/mix/tasks/phoenix_kit.update.ex">>,
604 605 <<"lib/mix/tasks/phoenix_kit.gen.migration.ex">>,
605 606 <<"lib/mix/tasks/phoenix_kit.status.ex">>,
  @@ -759,7 +760,7 @@
759 760 [{<<"name">>,<<"leaf">>},
760 761 {<<"app">>,<<"leaf">>},
761 762 {<<"optional">>,false},
762 - {<<"requirement">>,<<"~> 0.2.22">>},
763 + {<<"requirement">>,<<"~> 0.2.23">>},
763 764 {<<"repository">>,<<"hexpm">>}],
764 765 [{<<"name">>,<<"fresco">>},
765 766 {<<"app">>,<<"fresco">>},
  @@ -29,11 +29,6 @@ defmodule Mix.Tasks.Compile.PhoenixKitCssSources do
29 29 def run(_args) do
30 30 deps = Mix.Project.config()[:deps] || []
31 31
32 - # Ensure all dep applications are loaded for module discovery
33 - for dep <- Mix.Dep.cached() do
34 - Application.ensure_loaded(dep.app)
35 - end
36 -
37 32 source_lines =
38 33 PhoenixKit.ModuleDiscovery.discover_external_modules()
39 34 |> Enum.flat_map(fn mod ->
  @@ -47,6 +42,8 @@ defmodule Mix.Tasks.Compile.PhoenixKitCssSources do
47 42 |> Enum.sort()
48 43 |> Enum.map(&format_source(&1, deps))
49 44
45 + maybe_warn_zero_sources(source_lines)
46 +
50 47 content = """
51 48 /* Auto-generated by PhoenixKit — do not edit manually.
52 49 Regenerated on each compilation from css_sources/0 callbacks. */
  @@ -73,6 +70,30 @@ defmodule Mix.Tasks.Compile.PhoenixKitCssSources do
73 70 {:ok, []}
74 71 end
75 72
73 + # A silent empty file is the worst failure mode of CSS auto-discovery: the host's
74 + # admin UI loses every responsive/variant utility from the dep with no error. When
75 + # we emit zero @source lines but phoenix_kit-dependent deps are present on disk,
76 + # surface it loudly so it isn't a multi-hour CSS mystery.
77 + defp maybe_warn_zero_sources([]) do
78 + self = Mix.Project.config()[:app]
79 +
80 + case PhoenixKit.ModuleDiscovery.phoenix_kit_dependent_apps() -- [self, :phoenix_kit] do
81 + [] ->
82 + :ok
83 +
84 + apps ->
85 + Mix.shell().error(
86 + "[PhoenixKit] #{@generated_file} generated with 0 @source lines, but found " <>
87 + "phoenix_kit-dependent dep(s): #{inspect(apps)}. Their Tailwind classes will " <>
88 + "NOT be compiled — responsive/variant utilities used only inside those deps " <>
89 + "will be missing from the bundle. Ensure each module implements css_sources/0, " <>
90 + "or set `config :phoenix_kit, modules: [...]` as a fallback."
91 + )
92 + end
93 + end
94 +
95 + defp maybe_warn_zero_sources(_), do: :ok
96 +
76 97 # Absolute paths are emitted verbatim — prepending `../../` would
77 98 # produce `@source "../..//abs/path";` which Tailwind can't resolve
78 99 # reliably. Relative paths stay relative to the generated file at
  @@ -0,0 +1,306 @@
1 + defmodule Mix.Tasks.PhoenixKit.ReleaseCheck do
2 + @moduledoc """
3 + Asserts release-metadata consistency before publishing to Hex.
4 +
5 + Catches the class of mistakes that `precommit`/`quality.ci` cannot see —
6 + version/CHANGELOG/migration drift and unsafe git state — and exits non-zero
7 + on any failure so it can gate `mix hex.publish`. It is the semantic core of
8 + the `mix prerelease` alias and runs without a database.
9 +
10 + ## Usage
11 +
12 + $ mix phoenix_kit.release_check
13 + $ mix phoenix_kit.release_check --allow-dirty --allow-branch
14 +
15 + ## Options
16 +
17 + * `--allow-dirty` — downgrade the "working tree clean" check to a warning
18 + * `--allow-branch` — downgrade the "on main branch" check to a warning
19 +
20 + ## Checks Performed
21 +
22 + 1. **CHANGELOG Heading** — top `## X.Y.Z` entry matches `mix.exs` `@version`
23 + (and is a real version, not "Unreleased").
24 + 2. **CHANGELOG Body** — that entry has at least one content line.
25 + 3. **Migration Version Sync** — `Migrations.Postgres.current_version/0`
26 + equals the highest `vNNN.ex` migration file on disk.
27 + 4. **Git Tree Clean** — no uncommitted changes (`--allow-dirty` to warn).
28 + 5. **Git Branch** — on `main` (`--allow-branch` to warn).
29 + 6. **Tag Collision** — tag `v<version>` does not already exist (publish-
30 + before-tag means a pre-existing tag signals a double release).
31 + """
32 +
33 + use Mix.Task
34 +
35 + @shortdoc "Asserts version/CHANGELOG/migration/git consistency before a Hex release"
36 +
37 + @switches [allow_dirty: :boolean, allow_branch: :boolean]
38 +
39 + @impl Mix.Task
40 + def run(argv) do
41 + {opts, _argv, _errors} = OptionParser.parse(argv, switches: @switches)
42 +
43 + version = Mix.Project.config()[:version]
44 +
45 + header("PhoenixKit Release Check (v#{version})")
46 +
47 + results = [
48 + run_check("CHANGELOG Heading", fn -> check_changelog_heading(version) end),
49 + run_check("CHANGELOG Body", fn -> check_changelog_body(version) end),
50 + run_check("Migration Version Sync", fn -> check_migration_sync() end),
51 + run_check("Git Tree Clean", fn -> check_git_clean(opts) end),
52 + run_check("Git Branch", fn -> check_git_branch(opts) end),
53 + run_check("Tag Collision", fn -> check_tag_collision(version) end)
54 + ]
55 +
56 + IO.puts("")
57 + summary(results)
58 + end
59 +
60 + # ── Check implementations (return {:pass | :warn | :fail, detail}) ──
61 +
62 + defp check_changelog_heading(version) do
63 + case top_changelog_version() do
64 + {:ok, ^version} ->
65 + {:pass, "Top CHANGELOG entry is #{version}"}
66 +
67 + {:ok, other} ->
68 + {:fail,
69 + "mix.exs is #{version} but top CHANGELOG entry is #{other}. " <>
70 + "Add a `## #{version} - <date>` section before publishing."}
71 +
72 + :unreleased ->
73 + {:fail, "Top CHANGELOG entry is still \"Unreleased\" — stamp it with #{version}."}
74 +
75 + :error ->
76 + {:fail, "Could not find a `## X.Y.Z` heading in CHANGELOG.md"}
77 + end
78 + end
79 +
80 + defp check_changelog_body(version) do
81 + case changelog_body(version) do
82 + {:ok, body} ->
83 + if String.trim(body) == "" do
84 + {:fail, "The #{version} CHANGELOG section has no content."}
85 + else
86 + lines = body |> String.split("\n") |> Enum.count(&(String.trim(&1) != ""))
87 + {:pass, "#{lines} non-blank line(s)"}
88 + end
89 +
90 + :error ->
91 + {:fail, "No CHANGELOG section found for #{version}."}
92 + end
93 + end
94 +
95 + defp check_migration_sync do
96 + module = PhoenixKit.Migrations.Postgres
97 +
98 + if Code.ensure_loaded?(module) and function_exported?(module, :current_version, 0) do
99 + code_version = module.current_version()
100 +
101 + case highest_migration_file() do
102 + {:ok, file_version} when file_version == code_version ->
103 + {:pass, "current_version/0 == v#{file_version}.ex"}
104 +
105 + {:ok, file_version} ->
106 + {:fail,
107 + "Migrations.Postgres.current_version/0 is #{code_version} but the highest " <>
108 + "migration file is v#{file_version}.ex — register the new version (or " <>
109 + "remove the stray file)."}
110 +
111 + :error ->
112 + {:fail, "No vNNN.ex migration files found."}
113 + end
114 + else
115 + {:warn, "PhoenixKit.Migrations.Postgres.current_version/0 not available — skipped."}
116 + end
117 + end
118 +
119 + defp check_git_clean(opts) do
120 + case git(["status", "--porcelain"]) do
121 + {:ok, ""} ->
122 + {:pass, "No uncommitted changes"}
123 +
124 + {:ok, out} ->
125 + count = out |> String.split("\n", trim: true) |> length()
126 + detail = "#{count} uncommitted change(s). Commit or stash before publishing."
127 + if opts[:allow_dirty], do: {:warn, detail}, else: {:fail, detail}
128 +
129 + :error ->
130 + {:warn, "git unavailable — skipped."}
131 + end
132 + end
133 +
134 + defp check_git_branch(opts) do
135 + case git(["rev-parse", "--abbrev-ref", "HEAD"]) do
136 + {:ok, "main"} ->
137 + {:pass, "On main"}
138 +
139 + {:ok, branch} ->
140 + detail = "On #{branch}, not main. Releases are cut from main."
141 + if opts[:allow_branch], do: {:warn, detail}, else: {:fail, detail}
142 +
143 + :error ->
144 + {:warn, "git unavailable — skipped."}
145 + end
146 + end
147 +
148 + defp check_tag_collision(version) do
149 + tag = "v#{version}"
150 +
151 + case git(["tag", "-l", tag]) do
152 + {:ok, ""} ->
153 + {:pass, "#{tag} does not exist yet"}
154 +
155 + {:ok, _} ->
156 + {:fail, "Tag #{tag} already exists — this version looks already released."}
157 +
158 + :error ->
159 + {:warn, "git unavailable — skipped."}
160 + end
161 + end
162 +
163 + # ── CHANGELOG parsing ──────────────────────────────────────────────
164 +
165 + # Matches "## 1.7.138 - 2026-06-09", "## [1.7.138]", "## v1.7.138", etc.
166 + @heading_re ~r/^##\s+\[?v?(?<ver>\d+\.\d+\.\d+)/
167 +
168 + defp top_changelog_version do
169 + case File.read(changelog_path()) do
170 + {:ok, contents} ->
171 + heading = first_heading(contents) || ""
172 +
173 + cond do
174 + Regex.match?(~r/^##\s+\[?unreleased/im, heading) ->
175 + :unreleased
176 +
177 + match = Regex.named_captures(@heading_re, heading) ->
178 + {:ok, match["ver"]}
179 +
180 + true ->
181 + :error
182 + end
183 +
184 + _ ->
185 + :error
186 + end
187 + end
188 +
189 + # The first line starting with "## " — the latest entry.
190 + defp first_heading(contents) do
191 + contents
192 + |> String.split("\n")
193 + |> Enum.find(&String.starts_with?(&1, "## "))
194 + end
195 +
196 + defp changelog_body(version) do
197 + case File.read(changelog_path()) do
198 + {:ok, contents} ->
199 + lines = String.split(contents, "\n")
200 + target = ~r/^##\s+\[?v?#{Regex.escape(version)}\b/
201 +
202 + case Enum.find_index(lines, &Regex.match?(target, &1)) do
203 + nil ->
204 + :error
205 +
206 + idx ->
207 + body =
208 + lines
209 + |> Enum.drop(idx + 1)
210 + |> Enum.take_while(&(not String.starts_with?(&1, "## ")))
211 + |> Enum.join("\n")
212 +
213 + {:ok, body}
214 + end
215 +
216 + _ ->
217 + :error
218 + end
219 + end
220 +
221 + defp changelog_path, do: Path.join(File.cwd!(), "CHANGELOG.md")
222 +
223 + # ── Migration file discovery ───────────────────────────────────────
224 +
225 + defp highest_migration_file do
226 + "lib/phoenix_kit/migrations/postgres/v*.ex"
227 + |> Path.wildcard()
228 + |> Enum.map(&extract_migration_number/1)
229 + |> Enum.reject(&is_nil/1)
230 + |> case do
231 + [] -> :error
232 + nums -> {:ok, Enum.max(nums)}
233 + end
234 + end
235 +
236 + defp extract_migration_number(path) do
237 + case Regex.run(~r/v(\d+)\.ex$/, Path.basename(path)) do
238 + [_, num] -> String.to_integer(num)
239 + _ -> nil
240 + end
241 + end
242 +
243 + # ── Git ────────────────────────────────────────────────────────────
244 +
245 + defp git(args) do
246 + case System.cmd("git", args, stderr_to_stdout: true) do
247 + {out, 0} -> {:ok, String.trim(out)}
248 + _ -> :error
249 + end
250 + rescue
251 + _ -> :error
252 + end
253 +
254 + # ── Output (mirrors mix phoenix_kit.doctor) ────────────────────────
255 +
256 + defp header(title) do
257 + IO.puts("\n#{IO.ANSI.bright()}#{IO.ANSI.cyan()}#{title}#{IO.ANSI.reset()}")
258 + IO.puts(String.duplicate("─", 60))
259 + end
260 +
261 + defp run_check(name, fun) do
262 + result =
263 + try do
264 + fun.()
265 + rescue
266 + e -> {:fail, "Exception: #{Exception.message(e)}"}
267 + end
268 +
269 + display_check(name, result)
270 + {name, result}
271 + end
272 +
273 + defp display_check(name, {:pass, detail}) do
274 + IO.puts(" #{IO.ANSI.green()}PASS#{IO.ANSI.reset()} #{name}")
275 + if detail, do: IO.puts(" #{IO.ANSI.faint()}#{detail}#{IO.ANSI.reset()}")
276 + end
277 +
278 + defp display_check(name, {:warn, detail}) do
279 + IO.puts(" #{IO.ANSI.yellow()}WARN#{IO.ANSI.reset()} #{name}")
280 + if detail, do: IO.puts(" #{IO.ANSI.yellow()}#{detail}#{IO.ANSI.reset()}")
281 + end
282 +
283 + defp display_check(name, {:fail, detail}) do
284 + IO.puts(" #{IO.ANSI.red()}FAIL#{IO.ANSI.reset()} #{name}")
285 + if detail, do: IO.puts(" #{IO.ANSI.red()}#{detail}#{IO.ANSI.reset()}")
286 + end
287 +
288 + defp summary(results) do
289 + pass = Enum.count(results, fn {_, {status, _}} -> status == :pass end)
290 + warn = Enum.count(results, fn {_, {status, _}} -> status == :warn end)
291 + fail = Enum.count(results, fn {_, {status, _}} -> status == :fail end)
292 + total = length(results)
293 +
294 + IO.puts(
295 + "#{IO.ANSI.bright()}Summary#{IO.ANSI.reset()}: #{pass}/#{total} passed, #{warn} warnings, #{fail} failures"
296 + )
297 +
298 + if fail > 0 do
299 + IO.puts(
300 + "#{IO.ANSI.red()}Release blocked — fix the FAIL items above before publishing.#{IO.ANSI.reset()}"
301 + )
302 +
303 + exit({:shutdown, 1})
304 + end
305 + end
306 + end
  @@ -142,8 +142,7 @@ defmodule PhoenixKitWeb.Live.Modules.Maintenance.Page do
142 142 >
143 143 <p class="text-sm">
144 144 {gettext("Expected back in")}
145 - <span id="countdown-value" class="font-mono font-semibold text-base-content/70">
146 - </span>
145 + <span id="countdown-value" class="font-mono font-semibold text-base-content/70"></span>
147 146 </p>
148 147 </div>
149 148 <% end %>
Loading more files…