neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Actions: keep artifacts in their run and cache uploads in their repository
ff3668c · Gabriel Morell · 2026-10-17 14:33
Message
{commit_body(@commit)}
Files changed
modified
README.md
+2
−2
| 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 | 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 | 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 | 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 | 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 | 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
| 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 | 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 | 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 | 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 | 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 | 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