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
Current section
221 Versions
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…