neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Packages: a build cache isn't the latest version
308b64e · gmorell · 2026-11-10 01:18
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud/packages.ex
+12
−6
@@ -160,14 +160,20 @@ defmodule GitGud.Packages do
| 160 | 160 | end |
| 161 | 161 | |
| 162 | 162 | {:ok, v} = version |> PackageVersion.changeset(attrs) |> Repo.insert_or_update() |
| 163 | − {:ok, _} = package |> Package.changeset(%{latest_version: v.version}) |> Repo.update() | |
| 163 | + | |
| 164 | + # A build cache pushed after the real tags isn't the latest | |
| 165 | + # anything, unless it's all there is (`PackageVersion.build_cache?/1`). | |
| 166 | + unless PackageVersion.build_cache?(v) and package.latest_version do | |
| 167 | + {:ok, _} = package |> Package.changeset(%{latest_version: v.version}) |> Repo.update() | |
| 168 | + end | |
| 169 | + | |
| 164 | 170 | {package, v} |
| 165 | 171 | end) |
| 166 | 172 | end |
| 167 | 173 | |
| 168 | 174 | @doc """ |
| 169 | 175 | Remove a single version. If it was the package's `latest_version`, |
| 170 | − recompute against the next-most-recent push. Deletes the package | |
| 176 | + recompute against the next-most-recent push that isn't a build cache. Deletes the package | |
| 171 | 177 | row entirely if the version was the last one. |
| 172 | 178 | """ |
| 173 | 179 | def delete_version(%Repository{id: rid}, kind, name, version) do |
@@ -187,15 +193,15 @@ defmodule GitGud.Packages do
| 187 | 193 | from v in PackageVersion, |
| 188 | 194 | where: v.package_id == ^package.id, |
| 189 | 195 | order_by: [desc: v.pushed_at], |
| 190 | − limit: 1 | |
| 196 | + limit: 50 | |
| 191 | 197 | ) |
| 192 | 198 | |
| 193 | − case remaining do | |
| 194 | − [] -> | |
| 199 | + case PackageVersion.latest(remaining) do | |
| 200 | + nil -> | |
| 195 | 201 | Repo.delete!(package) |
| 196 | 202 | :deleted |
| 197 | 203 | |
| 198 | − [latest | _] -> | |
| 204 | + latest -> | |
| 199 | 205 | package |
| 200 | 206 | |> Package.changeset(%{latest_version: latest.version}) |
| 201 | 207 | |> Repo.update!() |
modified
lib/git_gud/packages/package_version.ex
+50
−0
@@ -70,4 +70,54 @@ defmodule GitGud.Packages.PackageVersion do
| 70 | 70 | |> validate_length(:version, max: 200) |
| 71 | 71 | |> unique_constraint([:package_id, :version]) |
| 72 | 72 | end |
| 73 | + | |
| 74 | + @cache_media_type "application/vnd.buildkit.cacheconfig.v0" | |
| 75 | + | |
| 76 | + @doc """ | |
| 77 | + Whether this tag is a BuildKit registry cache (`docker buildx build | |
| 78 | + --cache-to type=registry,ref=…:buildcache`) rather than an image | |
| 79 | + anyone would run. CI pushes it after the real tags, so taken at face | |
| 80 | + value it becomes the package's "latest" and its badge says | |
| 81 | + `buildcache`. | |
| 82 | + | |
| 83 | + Told by the manifest when the registry sent one: a cache is an image | |
| 84 | + whose config, or an index whose entries, carry BuildKit's cache | |
| 85 | + config media type. Without a manifest, by the name such tags have | |
| 86 | + (`buildcache`, `cli-buildcache`, `cache`). | |
| 87 | + """ | |
| 88 | + @spec build_cache?(%{optional(atom()) => term()}) :: boolean() | |
| 89 | + def build_cache?(version) do | |
| 90 | + doc = Map.get(version, :manifest_doc) | |
| 91 | + media_type = Map.get(version, :media_type) | |
| 92 | + | |
| 93 | + cond do | |
| 94 | + media_type == @cache_media_type -> true | |
| 95 | + is_map(doc) and doc != %{} -> cache_manifest?(doc) | |
| 96 | + true -> cache_tag?(Map.get(version, :version)) | |
| 97 | + end | |
| 98 | + end | |
| 99 | + | |
| 100 | + defp cache_manifest?(doc) do | |
| 101 | + config_type = get_in(doc, ["config", "mediaType"]) | |
| 102 | + | |
| 103 | + entry_types = | |
| 104 | + for %{"mediaType" => type} <- List.wrap(doc["manifests"]) ++ List.wrap(doc["layers"]), | |
| 105 | + do: type | |
| 106 | + | |
| 107 | + config_type == @cache_media_type or @cache_media_type in entry_types | |
| 108 | + end | |
| 109 | + | |
| 110 | + defp cache_tag?(tag) when is_binary(tag), | |
| 111 | + do: Regex.match?(~r/(^|[-_.])(build-?)?cache$/i, tag) | |
| 112 | + | |
| 113 | + defp cache_tag?(_), do: false | |
| 114 | + | |
| 115 | + @doc """ | |
| 116 | + The version a package's `latest_version` should name, of `versions` | |
| 117 | + newest first: the newest that isn't a build cache, or the newest at | |
| 118 | + all when there's nothing else. | |
| 119 | + """ | |
| 120 | + @spec latest([%{optional(atom()) => term()}]) :: map() | nil | |
| 121 | + def latest([]), do: nil | |
| 122 | + def latest([newest | _] = versions), do: Enum.find(versions, newest, &(not build_cache?(&1))) | |
| 73 | 123 | end |
modified
lib/git_gud/packages/pruner.ex
+7
−3
@@ -209,10 +209,14 @@ defmodule GitGud.Packages.Pruner do
| 209 | 209 | from(v in PackageVersion, |
| 210 | 210 | where: v.package_id == ^package.id, |
| 211 | 211 | order_by: [desc_nulls_last: v.pushed_at, desc: v.id], |
| 212 | − limit: 1, | |
| 213 | − select: v.version | |
| 212 | + limit: 50 | |
| 214 | 213 | ) |
| 215 | − |> Repo.one() | |
| 214 | + |> Repo.all() | |
| 215 | + |> PackageVersion.latest() | |
| 216 | + |> case do | |
| 217 | + nil -> nil | |
| 218 | + version -> version.version | |
| 219 | + end | |
| 216 | 220 | |
| 217 | 221 | package |
| 218 | 222 | |> Package.changeset(%{latest_version: latest}) |
added
priv/repo/migrations/20261110011023_skip_build_caches_as_latest_package_version.exs
+52
−0
@@ -0,0 +1,52 @@
| 1 | +defmodule GitGud.Repo.Migrations.SkipBuildCachesAsLatestPackageVersion do | |
| 2 | + @moduledoc """ | |
| 3 | + Packages whose `latest_version` is a BuildKit build cache (`buildcache`, | |
| 4 | + `cli-buildcache`), because CI pushes the cache after the real tags and | |
| 5 | + the newest push used to win. Each gets its newest version that isn't a | |
| 6 | + cache instead, by the same rule `PackageVersion.build_cache?/1` applies | |
| 7 | + to new pushes from now on: BuildKit's cache media type in the manifest, | |
| 8 | + or, with no manifest stored, a tag named like one. | |
| 9 | + | |
| 10 | + A package with nothing but caches keeps what it has. Data only, so | |
| 11 | + `down` does nothing: the old values were the bug. | |
| 12 | + """ | |
| 13 | + use Ecto.Migration | |
| 14 | + | |
| 15 | + @cache_media_type "application/vnd.buildkit.cacheconfig.v0" | |
| 16 | + | |
| 17 | + def up, do: execute(sql()) | |
| 18 | + | |
| 19 | + @doc "The repair, as one statement; public so it can be tested." | |
| 20 | + def sql do | |
| 21 | + cache = fn v -> | |
| 22 | + """ | |
| 23 | + (coalesce(#{v}.media_type, '') = '#{@cache_media_type}' | |
| 24 | + OR coalesce(#{v}.manifest_doc::text, '') LIKE '%#{@cache_media_type}%' | |
| 25 | + OR ((#{v}.manifest_doc IS NULL OR #{v}.manifest_doc::text = '{}') | |
| 26 | + AND #{v}.version ~* '(^|[-_.])(build-?)?cache$')) | |
| 27 | + """ | |
| 28 | + end | |
| 29 | + | |
| 30 | + """ | |
| 31 | + WITH stuck AS ( | |
| 32 | + SELECT p.id | |
| 33 | + FROM packages p | |
| 34 | + JOIN package_versions cur ON cur.package_id = p.id AND cur.version = p.latest_version | |
| 35 | + WHERE #{cache.("cur")} | |
| 36 | + ), | |
| 37 | + replacement AS ( | |
| 38 | + SELECT DISTINCT ON (v.package_id) v.package_id, v.version | |
| 39 | + FROM package_versions v | |
| 40 | + JOIN stuck ON stuck.id = v.package_id | |
| 41 | + WHERE NOT #{cache.("v")} | |
| 42 | + ORDER BY v.package_id, v.pushed_at DESC NULLS LAST, v.id DESC | |
| 43 | + ) | |
| 44 | + UPDATE packages p | |
| 45 | + SET latest_version = replacement.version, updated_at = now() | |
| 46 | + FROM replacement | |
| 47 | + WHERE replacement.package_id = p.id | |
| 48 | + """ | |
| 49 | + end | |
| 50 | + | |
| 51 | + def down, do: :ok | |
| 52 | +end |
added
test/git_gud/packages/build_cache_test.exs
+109
−0
@@ -0,0 +1,109 @@
| 1 | +defmodule GitGud.Packages.BuildCacheTest do | |
| 2 | + @moduledoc """ | |
| 3 | + A BuildKit registry cache (`--cache-to type=registry,ref=…:buildcache`) | |
| 4 | + is pushed after the real tags, and must not become a container | |
| 5 | + package's `latest_version` (`PackageVersion.build_cache?/1`). | |
| 6 | + """ | |
| 7 | + use GitGud.DataCase, async: true | |
| 8 | + | |
| 9 | + import GitGud.ForgeFixtures | |
| 10 | + | |
| 11 | + alias GitGud.Packages | |
| 12 | + alias GitGud.Packages.PackageVersion | |
| 13 | + | |
| 14 | + @cache "application/vnd.buildkit.cacheconfig.v0" | |
| 15 | + | |
| 16 | + describe "build_cache?/1" do | |
| 17 | + test "an index listing BuildKit's cache config" do | |
| 18 | + doc = %{ | |
| 19 | + "mediaType" => "application/vnd.oci.image.index.v1+json", | |
| 20 | + "manifests" => [ | |
| 21 | + %{"mediaType" => "application/vnd.oci.image.layer.v1.tar+gzip"}, | |
| 22 | + %{"mediaType" => @cache} | |
| 23 | + ] | |
| 24 | + } | |
| 25 | + | |
| 26 | + assert PackageVersion.build_cache?(%{version: "whatever", manifest_doc: doc}) | |
| 27 | + end | |
| 28 | + | |
| 29 | + test "an image whose config is BuildKit's cache config" do | |
| 30 | + doc = %{"config" => %{"mediaType" => @cache}, "layers" => []} | |
| 31 | + assert PackageVersion.build_cache?(%{version: "x", manifest_doc: doc}) | |
| 32 | + end | |
| 33 | + | |
| 34 | + test "a real image isn't one, whatever its tag is called" do | |
| 35 | + doc = %{"config" => %{"mediaType" => "application/vnd.oci.image.config.v1+json"}} | |
| 36 | + refute PackageVersion.build_cache?(%{version: "cache", manifest_doc: doc}) | |
| 37 | + end | |
| 38 | + | |
| 39 | + test "with no manifest, the tag's name decides" do | |
| 40 | + for tag <- ~w(buildcache cli-buildcache cache build-cache app_cache) do | |
| 41 | + assert PackageVersion.build_cache?(%{version: tag, manifest_doc: %{}}), tag | |
| 42 | + end | |
| 43 | + | |
| 44 | + for tag <- ~w(latest master v1.2.0 sha-cf87d70 cachet precache) do | |
| 45 | + refute PackageVersion.build_cache?(%{version: tag, manifest_doc: nil}), tag | |
| 46 | + end | |
| 47 | + end | |
| 48 | + end | |
| 49 | + | |
| 50 | + describe "latest_version" do | |
| 51 | + setup do | |
| 52 | + {_user, repo} = repository_fixture() | |
| 53 | + {:ok, repo: repo} | |
| 54 | + end | |
| 55 | + | |
| 56 | + defp push(repo, tag, at) do | |
| 57 | + {:ok, {package, _}} = | |
| 58 | + Packages.record_push(repo, "container", "app", %{ | |
| 59 | + version: tag, | |
| 60 | + digest: "sha256:" <> String.duplicate("a", 64), | |
| 61 | + pushed_at: at | |
| 62 | + }) | |
| 63 | + | |
| 64 | + Repo.reload!(package) | |
| 65 | + end | |
| 66 | + | |
| 67 | + test "a cache pushed after the real tags doesn't take it", %{repo: repo} do | |
| 68 | + push(repo, "v1.2.0", ~U[2026-11-05 16:09:41Z]) | |
| 69 | + push(repo, "latest", ~U[2026-11-05 16:09:42Z]) | |
| 70 | + package = push(repo, "buildcache", ~U[2026-11-05 16:10:09Z]) | |
| 71 | + | |
| 72 | + assert package.latest_version == "latest" | |
| 73 | + end | |
| 74 | + | |
| 75 | + test "a cache is the latest when it's all there is", %{repo: repo} do | |
| 76 | + assert push(repo, "buildcache", ~U[2026-11-05 16:10:09Z]).latest_version == "buildcache" | |
| 77 | + assert push(repo, "v1", ~U[2026-11-05 16:11:00Z]).latest_version == "v1" | |
| 78 | + end | |
| 79 | + | |
| 80 | + test "deleting the latest falls back past caches", %{repo: repo} do | |
| 81 | + push(repo, "v1", ~U[2026-11-05 16:00:00Z]) | |
| 82 | + push(repo, "buildcache", ~U[2026-11-05 16:05:00Z]) | |
| 83 | + push(repo, "v2", ~U[2026-11-05 16:10:00Z]) | |
| 84 | + | |
| 85 | + {:ok, :updated} = Packages.delete_version(repo, "container", "app", "v2") | |
| 86 | + | |
| 87 | + assert Packages.get_package_by(repo.id, "container", "app").latest_version == "v1" | |
| 88 | + end | |
| 89 | + | |
| 90 | + test "the migration repairs a package stuck on a cache", %{repo: repo} do | |
| 91 | + push(repo, "v1", ~U[2026-11-05 16:00:00Z]) | |
| 92 | + push(repo, "latest", ~U[2026-11-05 16:09:41Z]) | |
| 93 | + push(repo, "buildcache", ~U[2026-11-05 16:10:09Z]) | |
| 94 | + | |
| 95 | + # As the old rule left it: the newest push, the cache. | |
| 96 | + Packages.get_package_by(repo.id, "container", "app") | |
| 97 | + |> Ecto.Changeset.change(latest_version: "buildcache") | |
| 98 | + |> Repo.update!() | |
| 99 | + | |
| 100 | + Code.require_file( | |
| 101 | + "priv/repo/migrations/20261110011023_skip_build_caches_as_latest_package_version.exs" | |
| 102 | + ) | |
| 103 | + | |
| 104 | + Repo.query!(GitGud.Repo.Migrations.SkipBuildCachesAsLatestPackageVersion.sql()) | |
| 105 | + | |
| 106 | + assert Packages.get_package_by(repo.id, "container", "app").latest_version == "latest" | |
| 107 | + end | |
| 108 | + end | |
| 109 | +end |
Parents: 0317200