Add minimal /api/v1 REST API (repo + commit) for runner actions

fe5a1ea · gmorell · 2026-06-25 18:51

3 files +143 -23
Message
{commit_body(@commit)}

Files changed

modified .forgejo/workflows/build.yml
+11 −19
@@ -22,23 +22,17 @@ jobs:
22 22 username: gitgud-actions
23 23 password: ${{ github.token }}
24 24
25 # docker/metadata-action calls the forge REST API
26 # (/api/v1/.../commits/<sha>) for commit metadata, but the forge
27 # doesn't expose that API yet — it 404s and the action dies. Compute
28 # tags inline instead. Push to the job's OWN repo path
29 # (<registry>/<owner>/<repo>); the per-job token only grants push
30 # there. Only runs on master (see `on:`), so sha + latest suffice.
31 - name: Compute image tags
25 + # Push to the job's OWN repo path (<registry>/<owner>/<repo>); the
26 + # per-job token only grants push there. metadata-action reads repo +
27 + # commit metadata from the forge's /api/v1 REST API (Api.V1.RepositoryController).
28 + - uses: docker/metadata-action@v5
32 29 id: meta
33 shell: bash
34 run: |
35 IMAGE="${{ vars.CI_REGISTRY }}/${{ github.repository }}"
36 {
37 echo "tags<<EOF"
38 echo "${IMAGE}:sha-${{ github.sha }}"
39 echo "${IMAGE}:latest"
40 echo "EOF"
41 } >> "$GITHUB_OUTPUT"
30 + with:
31 + images: ${{ vars.CI_REGISTRY }}/${{ github.repository }}
32 + tags: |
33 + type=sha,format=long
34 + type=ref,event=branch
35 + type=raw,value=latest,enable={{is_default_branch}}
42 36
43 37 - uses: docker/build-push-action@v6
44 38 with:
@@ -46,8 +40,6 @@ jobs:
46 40 file: ./Dockerfile
47 41 push: true
48 42 tags: ${{ steps.meta.outputs.tags }}
49 labels: |
50 org.opencontainers.image.source=https://gg.neiam.co/${{ github.repository }}
51 org.opencontainers.image.revision=${{ github.sha }}
43 + labels: ${{ steps.meta.outputs.labels }}
52 44 cache-from: type=registry,ref=${{ vars.CI_REGISTRY }}/${{ github.repository }}:buildcache
53 45 cache-to: type=registry,ref=${{ vars.CI_REGISTRY }}/${{ github.repository }}:buildcache,mode=max
added lib/git_gud_web/controllers/api/v1/repository_controller.ex
+122 −0
@@ -0,0 +1,122 @@
1 +defmodule GitGudWeb.Api.V1.RepositoryController do
2 + @moduledoc """
3 + Minimal Gitea/GitHub-compatible REST API under `/api/v1`.
4 +
5 + forgejo-runner advertises `GITHUB_API_URL = <forge>/api/v1`, so common
6 + actions hit the forge's REST API. We implement the slice that
7 + `docker/metadata-action` (and friends) need:
8 +
9 + * `GET /repos/:owner/:name` — repo metadata (labels, default branch)
10 + * `GET /repos/:owner/:name/commits/:sha` — commit metadata (the `created` date)
11 +
12 + Public/internal repos are readable unauthenticated; private repos
13 + require a valid `RunnerJwt` (the per-job `github.token`) scoped to that
14 + repo, sent as `Authorization: token <jwt>` or `Bearer <jwt>`.
15 + """
16 + use GitGudWeb, :controller
17 +
18 + import Plug.Conn
19 +
20 + alias GitGud.Repositories
21 + alias GitGud.Repositories.Storage
22 + alias GitGud.Workflows.RunnerJwt
23 +
24 + def show(conn, %{"owner" => owner, "name" => name}) do
25 + with_repo(conn, owner, name, fn repo -> json(conn, repo_json(repo)) end)
26 + end
27 +
28 + def commit(conn, %{"owner" => owner, "name" => name, "sha" => sha}) do
29 + with_repo(conn, owner, name, fn repo ->
30 + case GitGud.Git.commit(repo.disk_path, sha) do
31 + {:ok, c} -> json(conn, commit_json(repo, c))
32 + _ -> not_found(conn)
33 + end
34 + end)
35 + end
36 +
37 + defp with_repo(conn, owner, name, fun) do
38 + case fetch_repo(owner, name) do
39 + nil -> not_found(conn)
40 + repo -> if authorized?(conn, repo), do: fun.(repo), else: unauthorized(conn)
41 + end
42 + end
43 +
44 + defp fetch_repo(owner, name) do
45 + Repositories.get_repository_by_path!(owner, name)
46 + |> GitGud.Repo.preload([:owner, :organization])
47 + rescue
48 + Ecto.NoResultsError -> nil
49 + end
50 +
51 + defp authorized?(conn, repo) do
52 + repo.visibility in ["public", "internal"] or runner_token_for?(conn, repo)
53 + end
54 +
55 + defp runner_token_for?(conn, repo) do
56 + with tok when is_binary(tok) <- bearer_token(conn),
57 + {:ok, %{"pid" => pid}} <- RunnerJwt.verify(tok) do
58 + pid == repo.id
59 + else
60 + _ -> false
61 + end
62 + end
63 +
64 + defp bearer_token(conn) do
65 + case get_req_header(conn, "authorization") do
66 + ["token " <> t | _] -> t
67 + ["Bearer " <> t | _] -> t
68 + _ -> nil
69 + end
70 + end
71 +
72 + defp repo_json(repo) do
73 + owner = Storage.repo_handle(repo)
74 +
75 + %{
76 + "id" => repo.id,
77 + "name" => repo.name,
78 + "full_name" => owner <> "/" <> repo.name,
79 + "description" => repo.description || "",
80 + "private" => repo.visibility == "private",
81 + "fork" => not is_nil(repo.parent_repository_id),
82 + "archived" => repo.archived,
83 + "default_branch" => repo.default_branch,
84 + "html_url" => repo_url(owner, repo.name),
85 + "clone_url" => repo_url(owner, repo.name) <> ".git",
86 + "created_at" => repo.inserted_at,
87 + "updated_at" => repo.updated_at,
88 + "owner" => %{"login" => owner, "username" => owner}
89 + }
90 + end
91 +
92 + defp commit_json(repo, c) do
93 + owner = Storage.repo_handle(repo)
94 + sha_hex = GitGud.Git.to_hex(c.sha)
95 + url = repo_url(owner, repo.name) <> "/commit/" <> sha_hex
96 +
97 + %{
98 + "sha" => sha_hex,
99 + "url" => url,
100 + "html_url" => url,
101 + "commit" => %{
102 + "message" => c.message,
103 + "author" => %{
104 + "name" => c.author_name,
105 + "email" => c.author_email,
106 + "date" => c.authored_at
107 + },
108 + "committer" => %{
109 + "name" => c.committer_name,
110 + "email" => c.committer_email,
111 + "date" => c.committed_at
112 + }
113 + },
114 + "parents" => Enum.map(c.parent_shas, fn p -> %{"sha" => GitGud.Git.to_hex(p)} end)
115 + }
116 + end
117 +
118 + defp repo_url(owner, name), do: GitGudWeb.Endpoint.url() <> "/r/" <> owner <> "/" <> name
119 +
120 + defp not_found(conn), do: conn |> put_status(404) |> json(%{"message" => "Not Found"})
121 + defp unauthorized(conn), do: conn |> put_status(401) |> json(%{"message" => "Unauthorized"})
122 +end
modified lib/git_gud_web/router.ex
+10 −4
@@ -222,10 +222,16 @@ defmodule GitGudWeb.Router do
222 222 end
223 223 end
224 224
225 # Other scopes may use custom stacks.
226 # scope "/api", GitGudWeb do
227 # pipe_through :api
228 # end
225 + # Gitea/GitHub-compatible REST API. forgejo-runner advertises
226 + # GITHUB_API_URL=<forge>/api/v1, so actions (docker/metadata-action, …)
227 + # hit these. Only the slice runner-driven actions need is implemented;
228 + # auth is handled per-action in the controller (public read / RunnerJwt).
229 + scope "/api/v1", GitGudWeb.Api.V1 do
230 + pipe_through :api
231 +
232 + get "/repos/:owner/:name", RepositoryController, :show
233 + get "/repos/:owner/:name/commits/:sha", RepositoryController, :commit
234 + end
229 235
230 236 # Enable LiveDashboard and Swoosh mailbox preview in development
231 237 if Application.compile_env(:git_gud, :dev_routes) do

Parents: d9cc682