Add minimal /api/v1 REST API (repo + commit) for runner actions
fe5a1ea · gmorell · 2026-06-25 18:51
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