Packages: a build cache isn't the latest version

308b64e · gmorell · 2026-11-10 01:18

14 files +230 -9
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