Actions: keep artifacts in their run and cache uploads in their repository

ff3668c · Gabriel Morell · 2026-10-17 14:33

26 files +755 -173
Message
{commit_body(@commit)}

Files changed

modified README.md
+2 −2
@@ -93,8 +93,8 @@ reinvent them.
93 93 - **`paths:` / `paths-ignore:`** with GitHub's pattern rules (`*` stays within a directory, `**` crosses them, `?`, `[a-z]`; in `paths:`, `!pattern` un-matches and a later positive pattern re-matches). A push runs if any changed file is selected by `paths:`, or if not every changed file is matched by `paths-ignore:`; both set → `paths:` wins, with a warning. Changed files are the ref's old → new diff (renames count as both paths); a new branch is diffed from where it left the default branch. Tag pushes and manual runs ignore path filters, and when the changed files can't be worked out (no default branch to compare with, an unknown base, a git error, more than 3000 files) the filter passes — a run is never skipped because the forge couldn't look
94 94 - Re-run / cancel buttons on the workflow show page
95 95 - Per-task HS256 JWT minted on `FetchTask`, gates artifact + cache endpoints
96 − - **GitHub Actions cache server v1** with hit matching (primary exact → fallback prefix, current ref → default ref) + age/size eviction worker
97 − - GitHub Actions artifacts API v4
96 + - **GitHub Actions cache server v1**, scoped to the repository. Restore order: the primary key exactly, then each fallback key as a prefix (newest first), each tried on the run's own ref, then — for a `pull_request` run — the PR's base branch, then the default branch; a save goes to the run's own ref. Upload chunks are placed at their `Content-Range` offsets (`actions/cache` sends four at once) and assembled on finalize; a finalized entry is immutable. Age/size eviction worker, which also clears uploads abandoned for a day. **Not reached by a default forgejo-runner**: with `cache.enabled` (its default) the runner starts its own cache server and points `ACTIONS_CACHE_URL` at it, ignoring the URL gitgud sends; jobs only use this one if the runner has `cache.enabled: false` and `ACTIONS_CACHE_URL` set in `runner.envs`. `actions/cache@v4`'s cache service v2 isn't implemented
97 + - A simplified **v1 "pipelines" artifact API**, scoped to the run: a job creates, lists and downloads its own run's artifacts only (so jobs of one run can share files). Not yet usable from the stock actions: forgejo-runner points `ACTIONS_RUNTIME_URL` at `/api/actions_pipeline/`, which isn't mounted, and `actions/upload-artifact@v3` / `download-artifact@v3` speak a different shape of that API (chunked `PUT`s with `itemPath`, container item listing). `@v4` (the Twirp `ArtifactService` under `ACTIONS_RESULTS_URL`) isn't supported
98 98 - Repo-scoped + org-scoped secrets/variables (AES-256-GCM at rest, key rotation)
99 99 - **Storage**
100 100 - Pluggable `GitGud.Storage` behaviour
modified lib/git_gud/workflows/actions_cache.ex
+103 −29
@@ -9,11 +9,12 @@ defmodule GitGud.Workflows.ActionsCache do
9 9 The runner sends `keys=k1,k2,...,kN` + `version=V`. We resolve hits
10 10 in this order, per the upstream spec:
11 11
12 − 1. Exact match on `k1` in the current ref
13 − 2. Exact match on `k1` in the repo's default ref
14 − 3. For each `k2..kN` (prefix match), most-recent-first:
15 − a. Current ref
16 − b. Default ref
12 + 1. Exact match on `k1` in the current ref, then in each fallback ref
13 + 2. For each `k2..kN` (prefix match), most-recent-first, in the
14 + current ref, then in each fallback ref
15 +
16 + The fallback refs are the repo's default branch, preceded for a
17 + `pull_request` run by the PR's base branch.
17 18
18 19 We only return finalized entries (`archived_at IS NOT NULL`).
19 20 Pending reservations are invisible to lookup.
@@ -25,6 +26,11 @@ defmodule GitGud.Workflows.ActionsCache do
25 26 short-lived presigned URL — for the Disk backend this routes
26 27 through `StorageController` with a token, for Garage it's a real
27 28 S3 presigned URL. Same primitive either way.
29 +
30 + While an upload is pending, each chunk lives at
31 + `<repo_id>/<row_id>.part.<offset>` (see `put_chunk/3`); `finalize/2`
32 + assembles them in offset order. `GitGud.Storage` has no multipart
33 + upload or server-side compose, so assembly happens in memory.
28 34 """
29 35
30 36 import Ecto.Query, warn: false
@@ -33,6 +39,7 @@ defmodule GitGud.Workflows.ActionsCache do
33 39 alias GitGud.Repositories.Repository
34 40 alias GitGud.Storage
35 41 alias GitGud.Workflows.CacheEntry
42 + alias GitGud.Workflows.CacheEntryPart
36 43
37 44 @bucket "gitgud-cache"
38 45 @download_expiry 300
@@ -48,20 +55,21 @@ defmodule GitGud.Workflows.ActionsCache do
48 55 Resolve a list of cache keys against the repo + version. Returns
49 56 the first matching `%CacheEntry{}` or `nil` if nothing matches.
50 57
51 − `default_ref` should be `"refs/heads/<default_branch>"`; pass `nil`
52 − to disable fallback to the default branch.
58 + `fallback_refs` are the refs tried after `ref`, in order — the PR's
59 + base branch for a `pull_request` run, then
60 + `"refs/heads/<default_branch>"`; `[]` disables fallback.
53 61 """
54 − @spec lookup(integer(), String.t(), [String.t()], String.t() | nil, String.t() | nil) ::
62 + @spec lookup(integer(), String.t(), [String.t()], String.t() | nil, [String.t() | nil]) ::
55 63 hit() | nil
56 − def lookup(_repo_id, _version, [], _ref, _default_ref), do: nil
64 + def lookup(_repo_id, _version, [], _ref, _fallback_refs), do: nil
57 65
58 − def lookup(repo_id, version, [primary | fallbacks], ref, default_ref) do
59 − refs = [ref, default_ref] |> Enum.reject(&is_nil/1) |> Enum.uniq()
66 + def lookup(repo_id, version, [primary | fallbacks], ref, fallback_refs) do
67 + refs = [ref | fallback_refs] |> Enum.reject(&is_nil/1) |> Enum.uniq()
60 68
69 + # 1. Exact primary on each ref in priority order.
70 + # 2. Prefix fallbacks across each ref.
61 71 result =
62 − # 1. Exact primary on each ref in priority order.
63 72 Enum.find_value(refs, &exact_match(repo_id, version, primary, &1)) ||
64 − # 2. Prefix fallbacks across each ref.
65 73 Enum.find_value(fallbacks, fn key ->
66 74 Enum.find_value(refs, &prefix_match(repo_id, version, key, &1))
67 75 end)
@@ -150,36 +158,102 @@ defmodule GitGud.Workflows.ActionsCache do
150 158 |> Ecto.Changeset.change(storage_key: real_key)
151 159 |> Repo.update()
152 160
161 + # A re-reservation starts the upload over: chunks left from an earlier,
162 + # unfinished attempt must not end up in this one.
163 + delete_parts(row)
164 +
153 165 {:ok, row}
154 166 end
155 167
156 168 defp storage_key_for(repo_id, row_id),
157 169 do: "#{repo_id}/#{row_id}"
158 170
159 − def get!(id), do: Repo.get!(CacheEntry, id)
171 + def get(id), do: Repo.get(CacheEntry, id)
160 172
161 173 @doc """
162 − Mark a row finalized + record its size. Called from the controller
163 − after the bytes are committed to Storage.
174 + Store one uploaded chunk of a pending entry: the bytes at `offset`
175 + (from the request's `Content-Range`). Each chunk is its own object and
176 + its own `cache_entry_parts` row, so the chunks `actions/cache` uploads
177 + concurrently never touch the same object; a retried chunk replaces the
178 + earlier one at that offset. `finalize/2` puts them together.
164 179 """
165 − def finalize(%CacheEntry{} = entry, size) when is_integer(size) do
166 − entry |> CacheEntry.finalize_changeset(size) |> Repo.update()
180 + def put_chunk(%CacheEntry{} = entry, offset, body)
181 + when is_integer(offset) and offset >= 0 and is_binary(body) do
182 + key = "#{entry.storage_key}.part.#{offset}"
183 + size = byte_size(body)
184 +
185 + with :ok <- Storage.put(@bucket, key, body, []) do
186 + Repo.insert!(
187 + %CacheEntryPart{cache_entry_id: entry.id, offset: offset, size: size, storage_key: key},
188 + on_conflict: [set: [size: size, storage_key: key, updated_at: DateTime.utc_now(:second)]],
189 + conflict_target: [:cache_entry_id, :offset]
190 + )
191 +
192 + :ok
193 + end
167 194 end
168 195
169 196 @doc """
170 − Append `body` to the row's storage object. The cache action issues
171 − multiple PATCHes with `Content-Range`; we concatenate via a simple
172 − read-modify-write because the cache objects are bounded (≤ few hundred
173 − MB) and writes are runner-serial.
197 + Assemble the entry's chunks, in offset order, into its storage object,
198 + delete the chunks, and mark the row finalized with its size.
199 +
200 + The chunks must tile `0..size-1` with no gap or overlap; otherwise
201 + `{:error, :incomplete}` and the entry stays pending. `size` is the
202 + total the client reports; `nil` takes the end of the contiguous chunks.
174 203 """
175 − def append(%CacheEntry{} = entry, body) when is_binary(body) do
176 − previous =
177 − case Storage.get(@bucket, entry.storage_key) do
178 − {:ok, bin} -> bin
179 − _ -> ""
180 − end
204 + def finalize(%CacheEntry{} = entry, size) when is_integer(size) or is_nil(size) do
205 + parts = parts_of(entry)
206 +
207 + with {:ok, total} <- tile(parts, size),
208 + {:ok, bodies} <- read_parts(parts),
209 + :ok <- Storage.put(@bucket, entry.storage_key, IO.iodata_to_binary(bodies), []) do
210 + {:ok, entry} = entry |> CacheEntry.finalize_changeset(total) |> Repo.update()
211 + delete_parts(parts)
212 + {:ok, entry}
213 + end
214 + end
181 215
182 − Storage.put(@bucket, entry.storage_key, previous <> body, [])
216 + @doc """
217 + Delete an entry's uploaded-but-unassembled chunks, objects first. Used
218 + when an upload starts over (`reserve/4`) and by the eviction worker for
219 + reservations that were never finalized.
220 + """
221 + def delete_parts(%CacheEntry{} = entry), do: entry |> parts_of() |> delete_parts()
222 +
223 + def delete_parts(parts) when is_list(parts) do
224 + Enum.each(parts, fn part ->
225 + _ = Storage.delete(@bucket, part.storage_key)
226 + _ = Repo.delete(part)
227 + end)
228 + end
229 +
230 + defp parts_of(%CacheEntry{id: id}) do
231 + Repo.all(from p in CacheEntryPart, where: p.cache_entry_id == ^id, order_by: p.offset)
232 + end
233 +
234 + # The parts, sorted by offset, must start at 0 and each begin where the
235 + # previous one ended.
236 + defp tile(parts, size) do
237 + contiguous_end =
238 + Enum.reduce_while(parts, 0, fn part, next ->
239 + if part.offset == next, do: {:cont, next + part.size}, else: {:halt, :gap}
240 + end)
241 +
242 + case contiguous_end do
243 + :gap -> {:error, :incomplete}
244 + total when is_nil(size) or total == size -> {:ok, total}
245 + _ -> {:error, :incomplete}
246 + end
247 + end
248 +
249 + defp read_parts(parts) do
250 + parts
251 + |> Enum.reduce_while({:ok, []}, fn part, {:ok, acc} ->
252 + case Storage.get(@bucket, part.storage_key) do
253 + {:ok, body} -> {:cont, {:ok, [acc, body]}}
254 + {:error, _} = err -> {:halt, err}
255 + end
256 + end)
183 257 end
184 258
185 259 @doc """
added lib/git_gud/workflows/cache_entry_part.ex
+21 −0
@@ -0,0 +1,21 @@
1 +defmodule GitGud.Workflows.CacheEntryPart do
2 + @moduledoc """
3 + One uploaded chunk of a pending `CacheEntry`: the bytes at
4 + `offset..offset+size-1` of the final object, stored under `storage_key`
5 + until `ActionsCache.finalize/2` assembles the entry.
6 + """
7 +
8 + use Ecto.Schema
9 +
10 + alias GitGud.Workflows.CacheEntry
11 +
12 + schema "cache_entry_parts" do
13 + field :offset, :integer
14 + field :size, :integer
15 + field :storage_key, :string
16 +
17 + belongs_to :cache_entry, CacheEntry
18 +
19 + timestamps(type: :utc_datetime)
20 + end
21 +end
modified lib/git_gud/workflows/workers/cache_eviction.ex
+28 −2
@@ -19,6 +19,12 @@ defmodule GitGud.Workflows.Workers.CacheEviction do
19 19 neither deletes rows nor touches storage. Useful for piloting
20 20 changes to the knobs.
21 21
22 + Independently of both knobs, reservations that were never finalized
23 + (`archived_at` nil) and haven't been touched for a day are abandoned
24 + uploads: their chunks (`cache_entry_parts`) and row are deleted. Age
25 + pruning can't see them — it keys on `last_used_at`/`archived_at`,
26 + both nil for a pending row.
27 +
22 28 Storage objects are deleted before the row, so an interruption mid-prune
23 29 leaves stranded blobs (cheap) rather than DB rows pointing at missing
24 30 objects (which would fail lookups).
@@ -52,9 +58,11 @@ defmodule GitGud.Workflows.Workers.CacheEviction do
52 58 size_count =
53 59 prune_by_size(Keyword.get(cfg, :per_repo_max_mb), dry_run?)
54 60
55 − if age_count + size_count > 0 do
61 + abandoned_count = prune_abandoned(dry_run?)
62 +
63 + if age_count + size_count + abandoned_count > 0 do
56 64 Logger.info(
57 − "cache eviction: age=#{age_count} size=#{size_count}#{if dry_run?, do: " (dry-run)", else: ""}"
65 + "cache eviction: age=#{age_count} size=#{size_count} abandoned=#{abandoned_count}#{if dry_run?, do: " (dry-run)", else: ""}"
58 66 )
59 67 end
60 68
@@ -127,12 +135,30 @@ defmodule GitGud.Workflows.Workers.CacheEviction do
127 135 evict(evictable, dry_run?)
128 136 end
129 137
138 + # ── abandoned uploads ───────────────────────────────────────────────
139 +
140 + @abandoned_after_seconds 86_400
141 +
142 + defp prune_abandoned(dry_run?) do
143 + cutoff = DateTime.utc_now() |> DateTime.add(-@abandoned_after_seconds, :second)
144 +
145 + abandoned =
146 + Repo.all(
147 + from c in CacheEntry,
148 + where: is_nil(c.archived_at) and c.updated_at < ^cutoff,
149 + select: c
150 + )
151 +
152 + evict(abandoned, dry_run?)
153 + end
154 +
130 155 # ── eviction ────────────────────────────────────────────────────────
131 156
132 157 defp evict(entries, true), do: length(entries)
133 158
134 159 defp evict(entries, false) do
135 160 Enum.each(entries, fn entry ->
161 + ActionsCache.delete_parts(entry)
136 162 _ = Storage.delete(ActionsCache.bucket(), entry.storage_key)
137 163 _ = Repo.delete(entry)
138 164 end)
modified lib/git_gud_web/controllers/actions_artifact_controller.ex
+115 −76
@@ -12,6 +12,10 @@ defmodule GitGudWeb.ActionsArtifactController do
12 12 * `GET /artifacts` — list
13 13 * `GET /artifacts/:id/download?itemPath` — download
14 14
15 + Every endpoint is scoped to the run the job token (`rid`) was minted
16 + for: a path naming another run, or an artifact of another run, is a
17 + 404.
18 +
15 19 Storage: `GitGud.Storage` (Garage). Each artifact row in
16 20 `workflow_artifacts` records `name`, `size`, `content_type`, and
17 21 `storage_key`.
@@ -32,111 +36,146 @@ defmodule GitGudWeb.ActionsArtifactController do
32 36 def create(conn, %{"run_id" => run_id} = params) do
33 37 name = (params["body"] && params["body"]["name"]) || params["name"] || params["Name"]
34 38
35 − if name == nil or name == "" do
36 − send_resp(conn, 400, "")
37 − else
38 − run_id = String.to_integer(run_id)
39 −
40 − artifact =
41 − %WorkflowArtifact{workflow_run_id: run_id}
42 − |> WorkflowArtifact.changeset(%{
43 − name: name,
44 − size: 0,
45 − content_type: "application/octet-stream",
46 − storage_key: storage_key(run_id, name)
47 − })
48 − |> Repo.insert!()
49 −
50 − body =
51 − Jason.encode!(%{
52 − "containerId" => artifact.id,
53 − "size" => 0,
54 − "name" => name,
55 − "fileContainerResourceUrl" => upload_url(conn, run_id, artifact.id)
56 − })
39 + case own_run(conn, run_id) do
40 + :error ->
41 + send_resp(conn, 404, "")
57 42
58 − conn
59 − |> put_resp_content_type("application/json", nil)
60 − |> send_resp(201, body)
43 + {:ok, _run_id} when name == nil or name == "" ->
44 + send_resp(conn, 400, "")
45 +
46 + {:ok, run_id} ->
47 + artifact =
48 + %WorkflowArtifact{workflow_run_id: run_id}
49 + |> WorkflowArtifact.changeset(%{
50 + name: name,
51 + size: 0,
52 + content_type: "application/octet-stream",
53 + storage_key: storage_key(run_id, name)
54 + })
55 + |> Repo.insert!()
56 +
57 + body =
58 + Jason.encode!(%{
59 + "containerId" => artifact.id,
60 + "size" => 0,
61 + "name" => name,
62 + "fileContainerResourceUrl" => upload_url(conn, run_id, artifact.id)
63 + })
64 +
65 + conn
66 + |> put_resp_content_type("application/json", nil)
67 + |> send_resp(201, body)
61 68 end
62 69 end
63 70
64 71 # ── upload ──────────────────────────────────────────────────────────
65 72
66 73 def upload(conn, %{"run_id" => run_id, "id" => artifact_id} = _params) do
67 − artifact = Repo.get!(WorkflowArtifact, String.to_integer(artifact_id))
68 −
69 − if artifact.workflow_run_id != String.to_integer(run_id) do
70 − send_resp(conn, 404, "")
74 + with {:ok, run_id} <- own_run(conn, run_id),
75 + %WorkflowArtifact{workflow_run_id: ^run_id} = artifact <- get_artifact(artifact_id) do
76 + case Plug.Conn.read_body(conn, length: 1_000_000_000) do
77 + {:ok, body, conn} -> store(conn, artifact, body)
78 + {:more, _partial, conn} -> send_resp(conn, 413, "")
79 + {:error, _} -> send_resp(conn, 400, "")
80 + end
71 81 else
72 − {:ok, body, conn} = Plug.Conn.read_body(conn, length: 1_000_000_000)
82 + _ -> send_resp(conn, 404, "")
83 + end
84 + end
73 85
74 − case Storage.put(@bucket, artifact.storage_key, body, []) do
75 − :ok ->
76 − {:ok, _} =
77 − artifact
78 − |> WorkflowArtifact.changeset(%{size: byte_size(body)})
79 − |> Repo.update()
86 + defp store(conn, artifact, body) do
87 + case Storage.put(@bucket, artifact.storage_key, body, []) do
88 + :ok ->
89 + {:ok, _} =
90 + artifact
91 + |> WorkflowArtifact.changeset(%{size: byte_size(body)})
92 + |> Repo.update()
80 93
81 − send_resp(conn, 200, "")
94 + send_resp(conn, 200, "")
82 95
83 − {:error, reason} ->
84 − conn
85 − |> put_resp_content_type("application/json", nil)
86 − |> send_resp(500, Jason.encode!(%{"error" => inspect(reason)}))
87 − end
96 + {:error, reason} ->
97 + conn
98 + |> put_resp_content_type("application/json", nil)
99 + |> send_resp(500, Jason.encode!(%{"error" => inspect(reason)}))
88 100 end
89 101 end
90 102
91 103 # ── finalize / list ─────────────────────────────────────────────────
92 104
93 − def finalize(conn, %{"run_id" => _run_id}) do
94 − # No-op — `upload/2` already persisted bytes + size.
95 − send_resp(conn, 200, "")
105 + def finalize(conn, %{"run_id" => run_id}) do
106 + case own_run(conn, run_id) do
107 + # No-op — `upload/2` already persisted bytes + size.
108 + {:ok, _run_id} -> send_resp(conn, 200, "")
109 + :error -> send_resp(conn, 404, "")
110 + end
96 111 end
97 112
98 113 def list(conn, %{"run_id" => run_id}) do
99 − artifacts =
100 − from(a in WorkflowArtifact, where: a.workflow_run_id == ^String.to_integer(run_id))
101 − |> Repo.all()
102 −
103 − body =
104 − Jason.encode!(%{
105 − "count" => length(artifacts),
106 − "value" =>
107 − Enum.map(artifacts, fn a ->
108 − %{
109 − "containerId" => a.id,
110 − "name" => a.name,
111 − "size" => a.size,
112 − "fileContainerResourceUrl" => upload_url(conn, a.workflow_run_id, a.id)
113 − }
114 − end)
115 − })
116 −
117 − conn
118 − |> put_resp_content_type("application/json", nil)
119 − |> send_resp(200, body)
120 − end
121 −
122 − # ── download ────────────────────────────────────────────────────────
114 + case own_run(conn, run_id) do
115 + {:ok, run_id} ->
116 + artifacts =
117 + from(a in WorkflowArtifact, where: a.workflow_run_id == ^run_id)
118 + |> Repo.all()
119 +
120 + body =
121 + Jason.encode!(%{
122 + "count" => length(artifacts),
123 + "value" =>
124 + Enum.map(artifacts, fn a ->
125 + %{
126 + "containerId" => a.id,
127 + "name" => a.name,
128 + "size" => a.size,
129 + "fileContainerResourceUrl" => upload_url(conn, a.workflow_run_id, a.id)
130 + }
131 + end)
132 + })
123 133
124 − def download(conn, %{"run_id" => _run_id, "id" => artifact_id}) do
125 − artifact = Repo.get!(WorkflowArtifact, String.to_integer(artifact_id))
126 −
127 − case Storage.get(@bucket, artifact.storage_key) do
128 − {:ok, body} ->
129 134 conn
130 − |> put_resp_content_type(artifact.content_type || "application/octet-stream", nil)
135 + |> put_resp_content_type("application/json", nil)
131 136 |> send_resp(200, body)
132 137
133 − {:error, _} ->
138 + :error ->
134 139 send_resp(conn, 404, "")
135 140 end
136 141 end
137 142
143 + # ── download ────────────────────────────────────────────────────────
144 +
145 + def download(conn, %{"run_id" => run_id, "id" => artifact_id}) do
146 + with {:ok, run_id} <- own_run(conn, run_id),
147 + %WorkflowArtifact{workflow_run_id: ^run_id} = artifact <- get_artifact(artifact_id),
148 + {:ok, body} <- Storage.get(@bucket, artifact.storage_key) do
149 + conn
150 + |> put_resp_content_type(artifact.content_type || "application/octet-stream", nil)
151 + |> send_resp(200, body)
152 + else
153 + _ -> send_resp(conn, 404, "")
154 + end
155 + end
156 +
138 157 # ── helpers ─────────────────────────────────────────────────────────
139 158
159 + # The path's run id, if it is the run the job token was minted for. A
160 + # job only ever sees its own run's artifacts (which is how jobs of one
161 + # run hand files to each other); any other run is a 404, not a 403, so
162 + # the id's existence isn't confirmed.
163 + defp own_run(conn, run_id_param) do
164 + with {run_id, ""} <- Integer.parse(run_id_param),
165 + ^run_id <- conn.assigns[:runner_run_id] do
166 + {:ok, run_id}
167 + else
168 + _ -> :error
169 + end
170 + end
171 +
172 + defp get_artifact(id) do
173 + case Integer.parse(id) do
174 + {id, ""} -> Repo.get(WorkflowArtifact, id)
175 + _ -> nil
176 + end
177 + end
178 +
140 179 defp storage_key(run_id, name) do
141 180 safe = String.replace(name, ~r/[^A-Za-z0-9._\-]/, "_")
142 181 "run-#{run_id}/#{safe}"
modified lib/git_gud_web/controllers/actions_cache_controller.ex
+89 −32

Click to load diff…

modified lib/git_gud_web/plugs/runner_jwt_auth.ex
+1 −0

Click to load diff…

added priv/repo/migrations/20261017142317_add_cache_entry_parts.exs
+22 −0

Click to load diff…

modified test/git_gud/workflows/actions_cache_test.exs
+78 −13

Click to load diff…

modified test/git_gud/workflows/cache_eviction_test.exs
+30 −2

Click to load diff…

modified test/git_gud_web/controllers/actions_endpoints_test.exs
+266 −17

Click to load diff…

Parents: 95a46eb