Package registries: per-namespace signing keys, and usage docs

aab4e3f · Gabriel Morell · 2026-09-11 15:02

23 files +1404 -164
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/packages.ex
+56 −24
@@ -365,10 +365,17 @@ defmodule GitGud.Packages do
365 365 Enum.join([repo_id, format, binary_part(sha256, 0, 2), sha256], "/")
366 366 end
367 367
368 − # ── per-repository signing keys ──────────────────────────────────────
368 + # ── per-namespace signing keys ───────────────────────────────────────
369 369
370 370 @doc """
371 − The repository's keypair for `format`, generating one on first use.
371 + The namespace's keypair for `format`, generating one on first use.
372 +
373 + Scoped to the namespace that owns `repo` — the organization, or the
374 + user for a personal repository — not to the repository itself. The
375 + key is a trust anchor for a *publisher*, and per-repository keys made
376 + a machine installing from five repositories in one organization carry
377 + five entries in `/etc/apt/keyrings` for what its sources list calls
378 + one publisher.
372 379
373 380 Returns `{:ok, %{public_pem: binary, private_pem: binary}}`. Hex and
374 381 apt both need a key before they can serve anything at all, so
@@ -384,26 +391,53 @@ defmodule GitGud.Packages do
384 391 the winner's row rather than clobbering it, because a public key that
385 392 changed underneath a consumer is worse than a wasted keygen.
386 393 """
387 − def signing_key(%Repository{id: rid} = repo, format) do
388 − case Repo.get_by(SigningKey, repository_id: rid, format: format) do
394 + def signing_key(%Repository{} = repo, format),
395 + do: signing_key(namespace(repo), format)
396 +
397 + def signing_key(owner, format) when is_struct(owner) do
398 + case get_signing_key(owner, format) do
389 399 %SigningKey{} = key -> unwrap_key(key)
390 − nil -> generate_signing_key(repo, format)
400 + nil -> generate_signing_key(owner, format)
391 401 end
392 402 end
393 403
394 − defp generate_signing_key(%Repository{id: rid} = repo, format) do
395 − {:ok, public_pem, private_pem} = generate_keypair(repo, format)
404 + @doc """
405 + A namespace's existing key for `format`, or `nil`.
406 +
407 + Never generates. The owner-facing key endpoint uses this so that
408 + asking for a key only ever confirms a namespace that has actually
409 + published something — a 404 otherwise, rather than an oracle that
410 + answers for every handle anyone cares to try.
411 + """
412 + def get_signing_key(owner, format) when is_struct(owner) do
413 + Repo.get_by(SigningKey, SigningKey.owner_fields(owner) ++ [format: format])
414 + end
415 +
416 + @doc """
417 + The namespace that owns a repository: its organization, or its user
418 + owner for a personal repository.
419 + """
420 + def namespace(%Repository{} = repo) do
421 + repo = Repo.preload(repo, [:owner, :organization])
422 + repo.organization || repo.owner
423 + end
424 +
425 + defp generate_signing_key(owner, format) do
426 + {:ok, public_pem, private_pem} = generate_keypair(owner, format)
396 427 {:ok, wrapped} = GitGud.Secrets.encrypt(private_pem)
397 428
398 − %SigningKey{repository_id: rid}
399 − |> SigningKey.changeset(%{
400 − format: format,
401 − public_key: public_pem,
402 − private_key_ciphertext: wrapped.ciphertext,
403 − private_key_iv: wrapped.iv,
404 − private_key_tag: wrapped.tag,
405 − key_id: wrapped.key_id
406 − })
429 + %SigningKey{}
430 + |> SigningKey.changeset(
431 + Map.new(SigningKey.owner_fields(owner))
432 + |> Map.merge(%{
433 + format: format,
434 + public_key: public_pem,
435 + private_key_ciphertext: wrapped.ciphertext,
436 + private_key_iv: wrapped.iv,
437 + private_key_tag: wrapped.tag,
438 + key_id: wrapped.key_id
439 + })
440 + )
407 441 |> Repo.insert()
408 442 |> case do
409 443 {:ok, _key} ->
@@ -411,9 +445,9 @@ defmodule GitGud.Packages do
411 445
412 446 {:error, _changeset} ->
413 447 # Another request generated one between our read and our write.
414 − case Repo.get_by(SigningKey, repository_id: rid, format: format) do
448 + case get_signing_key(owner, format) do
415 449 %SigningKey{} = key -> unwrap_key(key)
416 − nil -> signing_key(repo, format)
450 + nil -> signing_key(owner, format)
417 451 end
418 452 end
419 453 end
@@ -421,18 +455,16 @@ defmodule GitGud.Packages do
421 455 # apt needs an OpenPGP certificate, which OTP cannot produce — see
422 456 # `GitGud.Packages.Apt.Native`. Everything else wants RSA in PEM,
423 457 # which is the machinery the OCI token signer already has.
424 − defp generate_keypair(%Repository{} = repo, "deb") do
425 − repo = Repo.preload(repo, [:owner, :organization])
426 − handle = GitGud.Repositories.Storage.repo_handle(repo)
427 − user_id = "#{handle}/#{repo.name} (GitGud package registry)"
458 + defp generate_keypair(owner, "deb") do
459 + handle = GitGud.Repositories.Storage.owner_handle(owner)
428 460
429 − case GitGud.Packages.Apt.Native.generate_key(user_id) do
461 + case GitGud.Packages.Apt.Native.generate_key("#{handle} (GitGud package registry)") do
430 462 {:ok, {public, secret}} -> {:ok, public, secret}
431 463 {:error, reason} -> {:error, reason}
432 464 end
433 465 end
434 466
435 − defp generate_keypair(%Repository{}, _format) do
467 + defp generate_keypair(_owner, _format) do
436 468 {private_pem, public_pem} = Token.generate_keypair()
437 469 {:ok, public_pem, private_pem}
438 470 end
modified lib/git_gud/packages/auth.ex
+38 −3
@@ -15,23 +15,39 @@ defmodule GitGud.Packages.Auth do
15 15 So credential extraction tries, in order: HTTP Basic (either half may
16 16 carry the secret, since `__token__` occupies the username), a bare
17 17 `Authorization` value, and `Bearer`. Each candidate is then checked
18 − against the registry tokens, the user API tokens (`ggp_…`) and
19 − finally an email + password pair.
18 + against the registry tokens, the per-job CI tokens, the user API
19 + tokens (`ggp_…`) and finally an email + password pair.
20 +
21 + ## CI publishes without stored credentials
22 +
23 + A workflow's `${{ github.token }}` is a `GitGud.Workflows.RunnerJwt`
24 + minted for one job, and it authenticates here the same way it already
25 + does at the OCI token endpoint: it may read and write its *own*
26 + repository's packages, and nothing anywhere else. That is what lets a
27 + publish step be pasted into a workflow with no secret to create
28 + first — and it is the same deal the container registry already
29 + offers, so the two don't need different instructions.
20 30 """
21 31
22 32 alias GitGud.Accounts
23 33 alias GitGud.Accounts.User
24 34 alias GitGud.Packages
25 35 alias GitGud.Repositories.Repository
36 + alias GitGud.Workflows.RunnerJwt
26 37
27 38 @typedoc """
28 39 Who is making the request.
29 40
30 41 * `{:user, %User{}}` — a forge account, via password or `ggp_…`
31 42 * `{:token, repository_id, scopes}` — a repo-scoped `ggrt_…`
43 + * `{:job, repository_id}` — a running CI job's `${{ github.token }}`
32 44 * `:anonymous`
33 45 """
34 − @type principal :: {:user, User.t()} | {:token, integer(), [String.t()]} | :anonymous
46 + @type principal ::
47 + {:user, User.t()}
48 + | {:token, integer(), [String.t()]}
49 + | {:job, integer()}
50 + | :anonymous
35 51
36 52 @doc """
37 53 Identify the caller from the request's `Authorization` header.
@@ -87,6 +103,16 @@ defmodule GitGud.Packages.Auth do
87 103 {:token, repo_id, scopes}
88 104
89 105 :error ->
106 + verify_job_or_user(username, secret)
107 + end
108 + end
109 +
110 + defp verify_job_or_user(username, secret) do
111 + case RunnerJwt.verify(secret) do
112 + {:ok, %{"pid" => repo_id}} when is_integer(repo_id) ->
113 + {:job, repo_id}
114 +
115 + _ ->
90 116 case Accounts.verify_personal_access_token(secret) do
91 117 {:ok, %User{} = user, _scopes} ->
92 118 {:user, user}
@@ -118,6 +144,15 @@ defmodule GitGud.Packages.Auth do
118 144 def authorize({:token, _other, _}, %Repository{} = repo, action),
119 145 do: authorize(:anonymous, repo, action)
120 146
147 + # A job token carries the forge's own judgement that this job belongs
148 + # to this repository, so it reads and writes that repository's
149 + # packages with no stored credential — and, exactly as at the OCI
150 + # token endpoint, gets nothing at all anywhere else.
151 + def authorize({:job, repo_id}, %Repository{id: repo_id}, _action), do: true
152 +
153 + def authorize({:job, _other}, %Repository{} = repo, action),
154 + do: authorize(:anonymous, repo, action)
155 +
121 156 def authorize({:user, %User{} = user}, %Repository{} = repo, action) do
122 157 repo = GitGud.Repo.preload(repo, [:owner, :organization])
123 158
modified lib/git_gud/packages/signing_key.ex
+49 −9
@@ -1,18 +1,30 @@
1 1 defmodule GitGud.Packages.SigningKey do
2 2 @moduledoc """
3 − A repository's keypair for one registry format.
3 + A namespace's keypair for one registry format.
4 +
5 + Scoped to the namespace — an organization, or a user for personal
6 + repositories — rather than to a repository, because the key is a
7 + trust anchor for a *publisher*. Per-repository keys meant a machine
8 + installing from five repositories in one organization needed five
9 + entries in `/etc/apt/keyrings`, for what its sources list describes
10 + as one publisher.
4 11
5 12 The public half is served to clients verbatim — it is what a consumer
6 − pins with `mix hex.repo add --public-key` — and the private half is
7 − encrypted at rest with the `GitGud.Secrets` master key. Rows keep the
8 − `key_id` they were wrapped under so a master-key rotation can re-wrap
9 − them the same way secret rows are.
13 + pins with `mix hex.repo add --public-key` or `signed-by=` — and the
14 + private half is encrypted at rest with the `GitGud.Secrets` master
15 + key. Rows keep the `key_id` they were wrapped under so a master-key
16 + rotation can re-wrap them the same way secret rows are.
17 +
18 + Exactly one of `organization_id` / `user_id` is set; the database
19 + enforces it, because a row with neither is a private key nothing can
20 + ever look up again.
10 21 """
11 22 use Ecto.Schema
12 23
13 24 import Ecto.Changeset
14 25
15 − alias GitGud.Repositories.Repository
26 + alias GitGud.Accounts.User
27 + alias GitGud.Organizations.Organization
16 28
17 29 schema "package_signing_keys" do
18 30 field :format, :string
@@ -22,7 +34,8 @@ defmodule GitGud.Packages.SigningKey do
22 34 field :private_key_tag, :binary, redact: true
23 35 field :key_id, :string
24 36
25 − belongs_to :repository, Repository
37 + belongs_to :organization, Organization
38 + belongs_to :user, User
26 39
27 40 timestamps(type: :utc_datetime)
28 41 end
@@ -36,7 +49,9 @@ defmodule GitGud.Packages.SigningKey do
36 49 :private_key_ciphertext,
37 50 :private_key_iv,
38 51 :private_key_tag,
39 − :key_id
52 + :key_id,
53 + :organization_id,
54 + :user_id
40 55 ])
41 56 |> validate_required([
42 57 :format,
@@ -46,6 +61,31 @@ defmodule GitGud.Packages.SigningKey do
46 61 :private_key_tag,
47 62 :key_id
48 63 ])
49 − |> unique_constraint([:repository_id, :format])
64 + |> validate_one_owner()
65 + |> unique_constraint([:organization_id, :format],
66 + name: :package_signing_keys_organization_format_index
67 + )
68 + |> unique_constraint([:user_id, :format], name: :package_signing_keys_user_format_index)
69 + |> check_constraint(:organization_id, name: :package_signing_keys_one_owner)
50 70 end
71 +
72 + defp validate_one_owner(changeset) do
73 + case {get_field(changeset, :organization_id), get_field(changeset, :user_id)} do
74 + {nil, nil} ->
75 + add_error(changeset, :organization_id, "a signing key needs a namespace")
76 +
77 + {org, user} when not is_nil(org) and not is_nil(user) ->
78 + add_error(changeset, :organization_id, "a signing key belongs to one namespace")
79 +
80 + _ ->
81 + changeset
82 + end
83 + end
84 +
85 + @doc """
86 + The `belongs_to` field and id for a namespace, as the query and
87 + changeset both want it.
88 + """
89 + def owner_fields(%Organization{id: id}), do: [organization_id: id]
90 + def owner_fields(%User{id: id}), do: [user_id: id]
51 91 end
added lib/git_gud/packages/usage.ex
+441 −0
@@ -0,0 +1,441 @@
1 +defmodule GitGud.Packages.Usage do
2 + @moduledoc """
3 + The copy-pasteable instructions for each registry format.
4 +
5 + Two facets, because there are two audiences and they need different
6 + things:
7 +
8 + * **Package manager** — a person at a terminal, or a `mix.exs` /
9 + `Cargo.toml` / `requirements.txt` on someone's laptop. They need
10 + the line that points their tool at this repository, and the line
11 + that publishes to it.
12 +
13 + * **CI** — a workflow in this repository. It needs neither of those
14 + lines verbatim, because it has `${{ github.token }}` and no
15 + business creating a registry token at all. What it needs is a
16 + step it can paste.
17 +
18 + Kept out of the LiveView because it is a pile of string formatting
19 + with real rules in it (Hex checks the repository name it was added
20 + under; apt needs the key pinned before it will look at the repo at
21 + all), and that deserves tests of its own.
22 +
23 + Everything here is a pure function of the registry context, so a
24 + snippet can be asserted without rendering a page.
25 + """
26 +
27 + alias GitGud.Packages.Formats.Hex, as: HexFormat
28 + alias GitGud.Repositories.Repository
29 + alias GitGud.Repositories.Storage
30 +
31 + @typedoc """
32 + Everything a snippet needs to name this repository's registry.
33 + Build it with `context/2`.
34 + """
35 + @type context :: %{
36 + handle: String.t(),
37 + repo_name: String.t(),
38 + root: String.t(),
39 + hex_repo: String.t(),
40 + oci_host: String.t() | nil
41 + }
42 +
43 + @typedoc """
44 + One format's instructions. Any field may be `nil` where the format
45 + has nothing to say — OCI has no "configure" step, because the pull
46 + reference carries the host itself.
47 + """
48 + @type usage :: %{
49 + consume: [step()],
50 + publish: [step()],
51 + ci: [step()]
52 + }
53 +
54 + @typedoc "A labelled block of shell, YAML or config."
55 + @type step :: %{label: String.t(), language: String.t(), code: String.t()}
56 +
57 + @doc "Build the context the snippets are rendered from."
58 + @spec context(Repository.t(), String.t() | nil) :: context()
59 + def context(%Repository{} = repo, oci_host \\ nil) do
60 + repo = GitGud.Repo.preload(repo, [:owner, :organization])
61 +
62 + %{
63 + handle: Storage.repo_handle(repo),
64 + repo_name: repo.name,
65 + root: registry_root(),
66 + hex_repo: HexFormat.repo_name(repo),
67 + oci_host: oci_host
68 + }
69 + end
70 +
71 + defp registry_root do
72 + (Application.get_env(:git_gud, :package_registry_url) || GitGudWeb.Endpoint.url())
73 + |> String.trim_trailing("/")
74 + end
75 +
76 + @doc "The registry root URL for one format under this repository."
77 + @spec base_url(context(), String.t()) :: String.t()
78 + def base_url(ctx, format),
79 + do: "#{ctx.root}/pkg/#{format}/#{ctx.handle}/#{ctx.repo_name}"
80 +
81 + @doc """
82 + The namespace's public signing key for a format.
83 +
84 + This, not the per-repository path, is what instructions should point
85 + at: the key belongs to the namespace, so one keyring entry covers
86 + every repository the organization publishes.
87 + """
88 + @spec key_url(context(), String.t()) :: String.t()
89 + def key_url(ctx, format), do: "#{ctx.root}/pkg/_keys/#{format}/#{ctx.handle}"
90 +
91 + @doc """
92 + Instructions for `format`, or `nil` if there is nothing to say.
93 + """
94 + @spec for_format(String.t(), context()) :: usage() | nil
95 + def for_format(format, ctx)
96 +
97 + # ── PyPI ────────────────────────────────────────────────────────────
98 +
99 + def for_format("pypi", ctx) do
100 + url = base_url(ctx, "pypi")
101 +
102 + %{
103 + consume: [
104 + step(
105 + "Install from this index",
106 + "shell",
107 + """
108 + pip install --index-url #{url}/simple/ <package>
109 + """
110 + ),
111 + step(
112 + "…or pin it for a project",
113 + "shell",
114 + """
115 + # requirements.txt
116 + --index-url #{url}/simple/
117 + <package>==<version>
118 + """
119 + ),
120 + step(
121 + "A private repository needs a token",
122 + "shell",
123 + """
124 + pip install --index-url https://__token__:$GITGUD_TOKEN@#{strip_scheme(url)}/simple/ <package>
125 + """
126 + )
127 + ],
128 + publish: [
129 + step(
130 + "Upload with twine",
131 + "shell",
132 + """
133 + twine upload \\
134 + --repository-url #{url}/ \\
135 + --username __token__ \\
136 + --password $GITGUD_TOKEN \\
137 + dist/*
138 + """
139 + )
140 + ],
141 + ci: [
142 + step(
143 + "Publish from a workflow",
144 + "yaml",
145 + """
146 + - name: Publish to the forge
147 + run: |
148 + pip install build twine
149 + python -m build
150 + twine upload --repository-url #{url}/ dist/*
151 + env:
152 + # The per-job token. It may publish to this repository's
153 + # own registry and nothing else, so there is no secret to
154 + # create first.
155 + TWINE_USERNAME: __token__
156 + TWINE_PASSWORD: ${{ github.token }}
157 + """
158 + )
159 + ]
160 + }
161 + end
162 +
163 + # ── Hex ─────────────────────────────────────────────────────────────
164 +
165 + def for_format("hex", ctx) do
166 + url = base_url(ctx, "hex")
167 +
168 + %{
169 + consume: [
170 + step(
171 + "Add the repository — the key covers all of #{ctx.handle}",
172 + "shell",
173 + """
174 + mix hex.repo add #{ctx.hex_repo} #{url} \\
175 + --public-key=<(curl -fsSL #{key_url(ctx, "hex")})
176 + """
177 + ),
178 + step(
179 + "Depend on a package",
180 + "elixir",
181 + """
182 + # mix.exs — the `repo:` must match the name above; Hex checks it
183 + {:<package>, "~> <version>", repo: "#{ctx.hex_repo}"}
184 + """
185 + )
186 + ],
187 + publish: [
188 + step(
189 + "Build and publish a tarball",
190 + "shell",
191 + """
192 + mix hex.build # produces <name>-<version>.tar
193 + curl -fsSL --fail \\
194 + -H "Authorization: $GITGUD_TOKEN" \\
195 + -H "Content-Type: application/octet-stream" \\
196 + --data-binary @<name>-<version>.tar \\
197 + #{url}/publish
198 + """
199 + )
200 + ],
201 + ci: [
202 + step(
203 + "Publish from a workflow",
204 + "yaml",
205 + """
206 + - name: Publish to the forge
207 + run: |
208 + mix hex.build
209 + curl -fsSL --fail \\
210 + -H "Authorization: ${{ github.token }}" \\
211 + -H "Content-Type: application/octet-stream" \\
212 + --data-binary @$(ls *.tar) \\
213 + #{url}/publish
214 + """
215 + ),
216 + step(
217 + "…or consume from one",
218 + "yaml",
219 + """
220 + - name: Point mix at the forge
221 + run: |
222 + mix hex.repo add #{ctx.hex_repo} #{url} \\
223 + --public-key=<(curl -fsSL #{key_url(ctx, "hex")})
224 + mix deps.get
225 + """
226 + )
227 + ]
228 + }
229 + end
230 +
231 + # ── Cargo ───────────────────────────────────────────────────────────
232 +
233 + def for_format("cargo", ctx) do
234 + url = base_url(ctx, "cargo")
235 + registry = ctx.repo_name
236 +
237 + %{
238 + consume: [
239 + step(
240 + "Register the index",
241 + "toml",
242 + """
243 + # .cargo/config.toml
244 + [registries.#{registry}]
245 + index = "sparse+#{url}/"
246 + """
247 + ),
248 + step(
249 + "Depend on a crate",
250 + "toml",
251 + """
252 + # Cargo.toml
253 + [dependencies]
254 + <crate> = { version = "<version>", registry = "#{registry}" }
255 + """
256 + )
257 + ],
258 + publish: [
259 + step(
260 + "Log in, then publish",
261 + "shell",
262 + """
263 + cargo login --registry #{registry} $GITGUD_TOKEN
264 + cargo publish --registry #{registry}
265 + """
266 + ),
267 + step(
268 + "Yank a bad release",
269 + "shell",
270 + """
271 + cargo yank --registry #{registry} <crate>@<version>
272 + """
273 + )
274 + ],
275 + ci: [
276 + step(
277 + "Publish from a workflow",
278 + "yaml",
279 + """
280 + - name: Publish to the forge
281 + run: |
282 + mkdir -p .cargo
283 + cat >> .cargo/config.toml <<'TOML'
284 + [registries.#{registry}]
285 + index = "sparse+#{url}/"
286 + TOML
287 + cargo publish --registry #{registry}
288 + env:
289 + # Cargo reads the token for registry `<name>` from
290 + # CARGO_REGISTRIES_<NAME>_TOKEN. The per-job token may
291 + # publish to this repository only.
292 + CARGO_REGISTRIES_#{env_name(registry)}_TOKEN: ${{ github.token }}
293 + """
294 + )
295 + ]
296 + }
297 + end
298 +
299 + # ── apt ─────────────────────────────────────────────────────────────
300 +
301 + def for_format("deb", ctx) do
302 + url = base_url(ctx, "deb")
303 + name = ctx.repo_name
304 +
305 + %{
306 + consume: [
307 + step(
308 + "Trust #{ctx.handle}'s key — once per machine, for every repository it publishes",
309 + "shell",
310 + """
311 + sudo install -d -m 0755 /etc/apt/keyrings
312 + curl -fsSL #{key_url(ctx, "deb")} \\
313 + | sudo tee /etc/apt/keyrings/#{ctx.handle}.asc > /dev/null
314 + """
315 + ),
316 + step(
317 + "Then add this repository",
318 + "shell",
319 + """
320 + echo "deb [signed-by=/etc/apt/keyrings/#{ctx.handle}.asc] #{url} stable main" \\
321 + | sudo tee /etc/apt/sources.list.d/#{name}.list
322 + sudo apt-get update
323 + """
324 + ),
325 + step(
326 + "A private repository needs a token",
327 + "shell",
328 + """
329 + # /etc/apt/auth.conf.d/#{name}.conf (chmod 600)
330 + machine #{host_of(url)}
331 + login __token__
332 + password <ggrt_…>
333 + """
334 + )
335 + ],
336 + publish: [
337 + step(
338 + "Upload a .deb",
339 + "shell",
340 + """
341 + curl -fsSL --fail \\
342 + -H "Authorization: $GITGUD_TOKEN" \\
343 + -H "Content-Type: application/octet-stream" \\
344 + --data-binary @<package>_<version>_<arch>.deb \\
345 + "#{url}/upload?dist=stable&component=main"
346 + """
347 + )
348 + ],
349 + ci: [
350 + step(
351 + "Publish from a workflow",
352 + "yaml",
353 + """
354 + - name: Publish to the forge
355 + run: |
356 + for deb in target/debian/*.deb; do
357 + curl -fsSL --fail \\
358 + -H "Authorization: ${{ github.token }}" \\
359 + -H "Content-Type: application/octet-stream" \\
360 + --data-binary @"$deb" \\
361 + "#{url}/upload?dist=stable&component=main"
362 + done
363 + """
364 + )
365 + ]
366 + }
367 + end
368 +
369 + # ── OCI ─────────────────────────────────────────────────────────────
370 +
371 + # Container images are zot's, not ours; these instructions describe
372 + # the registry the images actually live in.
373 + def for_format("oci", %{oci_host: host} = ctx) when is_binary(host) do
374 + image = "#{host}/#{ctx.handle}/#{ctx.repo_name}"
375 +
376 + %{
377 + consume: [step("Pull an image", "shell", "podman pull #{image}:<tag>\n")],
378 + publish: [
379 + step(
380 + "Log in, then push",
381 + "shell",
382 + """
383 + podman login #{host} --username <you> --password $GITGUD_TOKEN
384 + podman push #{image}:<tag>
385 + """
386 + )
387 + ],
388 + ci: [
389 + step(
390 + "Publish from a workflow",
391 + "yaml",
392 + """
393 + - uses: docker/login-action@v3
394 + with:
395 + registry: #{host}
396 + # Ignored by the token endpoint, but login-action insists
397 + # on something non-empty.
398 + username: gitgud-actions
399 + password: ${{ github.token }}
400 +
401 + - uses: docker/build-push-action@v6
402 + with:
403 + push: true
404 + tags: #{image}:${{ github.sha }}
405 + """
406 + )
407 + ]
408 + }
409 + end
410 +
411 + def for_format(_format, _ctx), do: nil
412 +
413 + @doc """
414 + The one-line summary shown next to a format heading — what to say
415 + when the reader hasn't opened the instructions yet.
416 + """
417 + @spec summary(String.t()) :: String.t() | nil
418 + def summary("pypi"), do: "PEP 503 / PEP 691 simple index — pip, uv, poetry, twine."
419 + def summary("hex"), do: "Hex registry v2, signed. Works with mix out of the box."
420 + def summary("cargo"), do: "Sparse HTTP index — cargo publish, add, yank."
421 + def summary("deb"), do: "A signed Debian repository — apt-get update and install."
422 + def summary("oci"), do: "Container images, served by the bundled OCI registry."
423 + def summary(_), do: nil
424 +
425 + # ── helpers ─────────────────────────────────────────────────────────
426 +
427 + defp step(label, language, code),
428 + do: %{label: label, language: language, code: String.trim_trailing(code)}
429 +
430 + # Cargo maps `registries.my-crate` to CARGO_REGISTRIES_MY_CRATE_TOKEN:
431 + # uppercased, with every non-alphanumeric run folded to `_`.
432 + defp env_name(registry) do
433 + registry
434 + |> String.upcase()
435 + |> String.replace(~r/[^A-Z0-9]+/, "_")
436 + end
437 +
438 + defp strip_scheme(url), do: String.replace(url, ~r{^https?://}, "")
439 +
440 + defp host_of(url), do: url |> URI.parse() |> Map.get(:host) || "localhost"
441 +end
modified lib/git_gud_web/controllers/package_registry_controller.ex
+34 −0
@@ -25,6 +25,7 @@ defmodule GitGudWeb.PackageRegistryController do
25 25 alias GitGud.Packages.Auth
26 26 alias GitGud.Packages.Format
27 27 alias GitGud.Packages.PackageFile
28 + alias GitGud.Packages.SigningKey
28 29 alias GitGud.Repositories
29 30
30 31 # Upload bodies we read ourselves. Well above a fat manylinux wheel;
@@ -34,6 +35,39 @@ defmodule GitGudWeb.PackageRegistryController do
34 35 def index(conn, params), do: dispatch(conn, params, :read)
35 36 def store(conn, params), do: dispatch(conn, params, :write)
36 37
38 + @doc """
39 + The namespace's public signing key for a format:
40 +
41 + GET /pkg/_keys/:format/:owner
42 +
43 + Signing keys belong to the namespace, so this is the URL to pin —
44 + one `signed-by=` keyring entry, or one `--public-key`, covering every
45 + repository the organization publishes. The per-repository key paths
46 + still work and serve the same bytes; this one exists so the
47 + instructions can say "once per organization" and mean it.
48 +
49 + Unauthenticated, and deliberately so: a public key is not a secret,
50 + and apt and mix both need it before any credential is configured. It
51 + is served only where one already exists — generating on demand here
52 + would turn the endpoint into an oracle that confirms any handle
53 + anyone cares to type, whereas this only answers for a namespace that
54 + has actually published something.
55 + """
56 + def owner_key(conn, %{"format" => format, "owner" => owner}) do
57 + with {:ok, _mod} <- adapter(format),
58 + {:ok, namespace} <- Repositories.resolve_namespace(owner),
59 + %SigningKey{public_key: key} <- Packages.get_signing_key(namespace, format) do
60 + conn
61 + |> put_resp_content_type_raw(key_content_type(format))
62 + |> send_resp(200, key)
63 + else
64 + _ -> send_error(conn, format, 404, "no signing key published for #{owner}")
65 + end
66 + end
67 +
68 + defp key_content_type("deb"), do: "application/pgp-keys"
69 + defp key_content_type(_format), do: "text/plain; charset=utf-8"
70 +
37 71 defp dispatch(conn, %{"format" => format, "owner" => owner, "name" => name} = params, action) do
38 72 with {:ok, mod} <- adapter(format),
39 73 {:ok, repo} <- fetch_repo(owner, name),
modified lib/git_gud_web/live/repo_live/packages.ex
+263 −128
@@ -1,19 +1,31 @@
1 1 defmodule GitGudWeb.RepoLive.Packages do
2 2 @moduledoc """
3 3 Everything a repository has published that you can actually install,
4 − grouped by registry format.
5 −
6 − Container images are mirrored from zot via `/_webhooks/zot`;
7 − everything else was uploaded to this forge's own registries under
8 − `/pkg/:format/:owner/:name`. Each format carries the line you paste
9 − to point a client at it, and each version the line you paste to
10 − install that version — the page is only useful if you can copy from
11 − it without going to look anything up.
4 + plus how to install and publish it.
5 +
6 + Two halves, because they answer different questions:
7 +
8 + * **What's here** — packages grouped by registry format, each
9 + version carrying the one line that installs *that* version.
10 + Container images are mirrored from zot via `/_webhooks/zot`;
11 + everything else was uploaded to this forge's own registries
12 + under `/pkg/:format/:owner/:name`.
13 +
14 + * **How to use it** — the instructions, per format, split by the
15 + two audiences that need different things: someone at a terminal
16 + with a package manager, and a workflow in this repository. The
17 + snippets come from `GitGud.Packages.Usage`; this module only
18 + decides which one you're looking at.
19 +
20 + The second half shows every format the forge serves, not just the
21 + ones with something published — "nothing here yet" is exactly when
22 + you want to know how to publish.
12 23 """
13 24
14 25 use GitGudWeb, :live_view
15 26
16 27 alias GitGud.Packages
28 + alias GitGud.Packages.Usage
17 29 alias GitGud.Repositories
18 30 alias GitGud.Repositories.Storage
19 31
@@ -29,33 +41,37 @@ defmodule GitGudWeb.RepoLive.Packages do
29 41 "deb" => "Debian packages"
30 42 }
31 43
44 + @facets [
45 + {"manager", "Package manager"},
46 + {"ci", "CI workflow"}
47 + ]
48 +
32 49 @impl true
33 50 def mount(%{"owner" => owner, "name" => name}, _session, socket) do
34 51 repo =
35 52 Repositories.get_repository_by_path!(owner, name)
36 53 |> GitGud.Repo.preload([:owner, :organization])
37 54
38 − handle = Storage.repo_handle(repo)
55 + oci_host = Application.get_env(:git_gud, :registry_host)
56 + groups = grouped_packages(repo)
39 57
40 58 {:ok,
41 59 socket
42 60 |> assign(:repo, repo)
43 − |> assign(:handle, handle)
61 + |> assign(:handle, Storage.repo_handle(repo))
44 62 |> GitGudWeb.RepoLive.Header.assign_chrome(repo)
45 − |> assign(:groups, grouped_packages(repo))
63 + |> assign(:groups, groups)
46 64 |> assign(:expanded_id, nil)
47 65 |> assign(:versions_by_id, %{})
48 − |> assign(:registry, %{
49 − handle: handle,
50 − repo_name: repo.name,
51 − # The OCI registry is a separate host (zot); everything else is
52 − # served by this forge.
53 − host: Application.get_env(:git_gud, :registry_host),
54 − root: registry_root(),
55 − # The name `mix hex.repo add` must use; Hex checks it against
56 − # the registry payload.
57 − hex_repo: GitGud.Packages.Formats.Hex.repo_name(repo)
58 − })
66 + |> assign(:usage_ctx, Usage.context(repo, oci_host))
67 + # `@…` in a template is an assign, never a module attribute, so
68 + # the two lists the tab strips iterate have to come through here.
69 + |> assign(:format_order, @format_order)
70 + |> assign(:facets, @facets)
71 + # Open the help on a format that actually has packages, so the
72 + # common case needs no clicking.
73 + |> assign(:help_format, default_help_format(groups))
74 + |> assign(:help_facet, "manager")
59 75 |> assign(:page_title, "Packages — #{owner}/#{name}")}
60 76 end
61 77
@@ -63,13 +79,13 @@ defmodule GitGudWeb.RepoLive.Packages do
63 79 repo
64 80 |> Packages.list_packages()
65 81 |> Enum.group_by(& &1.format)
66 − |> Enum.sort_by(fn {format, _} -> Enum.find_index(@format_order, &(&1 == format)) || 99 end)
82 + |> Enum.sort_by(fn {format, _} -> order_of(format) end)
67 83 end
68 84
69 − defp registry_root do
70 − (Application.get_env(:git_gud, :package_registry_url) || GitGudWeb.Endpoint.url())
71 − |> String.trim_trailing("/")
72 − end
85 + defp order_of(format), do: Enum.find_index(@format_order, &(&1 == format)) || 99
86 +
87 + defp default_help_format([{format, _packages} | _]), do: format
88 + defp default_help_format([]), do: "pypi"
73 89
74 90 @impl true
75 91 def handle_event("toggle", %{"id" => id}, socket) do
@@ -95,11 +111,17 @@ defmodule GitGudWeb.RepoLive.Packages do
95 111 end
96 112 end
97 113
114 + def handle_event("help-format", %{"format" => format}, socket),
115 + do: {:noreply, assign(socket, :help_format, format)}
116 +
117 + def handle_event("help-facet", %{"facet" => facet}, socket),
118 + do: {:noreply, assign(socket, :help_facet, facet)}
119 +
98 120 @impl true
99 121 def render(assigns) do
100 122 ~H"""
101 123 <Layouts.app flash={@flash} current_scope={@current_scope}>
102 − <div class="space-y-6">
124 + <div class="space-y-8">
103 125 <GitGudWeb.RepoLive.Header.header
104 126 repo={@repo}
105 127 handle={@handle}
@@ -112,144 +134,257 @@ defmodule GitGudWeb.RepoLive.Packages do
112 134 <header>
113 135 <h1 class="text-xl font-semibold">Packages</h1>
114 136 <p class="text-xs opacity-70 mt-1">
115 − Artifacts published from this repository, by format. Container
116 − images live in the OCI registry; everything else is served from
117 − this forge under <code class="font-mono">/pkg</code>.
137 + Artifacts published from this repository. Container images live in
138 + the OCI registry; everything else is served from this forge under <code class="font-mono">/pkg</code>.
118 139 </p>
119 140 </header>
120 141
121 − <p
122 − :if={@groups == []}
123 − class="px-3 py-6 text-sm opacity-60 text-center border border-base-300 rounded"
124 − >
125 − Nothing published yet.
126 − </p>
127 −
128 − <section :for={{format, packages} <- @groups} class="space-y-2">
129 − <div class="flex items-baseline justify-between gap-3">
130 − <h2 class="text-sm font-semibold">{format_label(format)}</h2>
131 − <span class="text-xs opacity-50">{length(packages)}</span>
132 − </div>
133 −
134 − <code
135 − :if={configure_line(format, @registry)}
136 − class="block bg-base-200 rounded px-2 py-1 select-all text-[11px] overflow-x-auto"
142 + <div class="space-y-4">
143 + <p
144 + :if={@groups == []}
145 + class="px-3 py-6 text-sm opacity-60 text-center border border-base-300 border-dashed rounded"
137 146 >
138 − {configure_line(format, @registry)}
139 − </code>
147 + Nothing published yet — the instructions below cover how to.
148 + </p>
140 149
141 − <ul class="divide-y divide-base-300 border border-base-300 rounded">
142 − <li :for={pkg <- packages} class="px-3 py-2">
150 + <section :for={{format, packages} <- @groups} class="space-y-2">
151 + <div class="flex items-baseline justify-between gap-3">
152 + <h2 class="text-sm font-semibold">{format_label(format)}</h2>
143 153 <button
144 154 type="button"
145 − phx-click="toggle"
146 − phx-value-id={pkg.id}
147 − class="w-full flex items-baseline justify-between gap-3 text-left"
155 + phx-click="help-format"
156 + phx-value-format={format}
157 + class="text-xs opacity-60 hover:opacity-100 underline underline-offset-2"
148 158 >
149 − <div>
150 − <p class="font-mono text-sm">{pkg.name}</p>
151 − <p :if={pkg.latest_version} class="text-xs opacity-60">
152 − latest: <span class="font-mono">{pkg.latest_version}</span>
153 − </p>
154 − </div>
155 − <.icon
156 − name={if @expanded_id == pkg.id, do: "hero-chevron-up", else: "hero-chevron-down"}
157 − class="size-4 opacity-50"
158 − />
159 + How to use
159 160 </button>
160 −
161 − <ul
162 − :if={@expanded_id == pkg.id}
163 − class="mt-2 ml-4 border-l border-base-300 pl-3 space-y-2 text-xs"
164 − >
165 − <li :for={v <- Map.get(@versions_by_id, pkg.id, [])} class="space-y-1">
166 − <div class="flex items-baseline justify-between gap-2">
167 − <span class="font-mono">
168 − {v.version}
169 − <span :if={v.yanked} class="badge badge-xs badge-warning ml-1">yanked</span>
170 − </span>
171 − <span :if={v.digest} class="opacity-60 truncate">{short_digest(v.digest)}</span>
172 − <span class="opacity-50">{format_dt(v.pushed_at)}</span>
161 + </div>
162 +
163 + <ul class="divide-y divide-base-300 border border-base-300 rounded">
164 + <li :for={pkg <- packages} class="px-3 py-2">
165 + <button
166 + type="button"
167 + phx-click="toggle"
168 + phx-value-id={pkg.id}
169 + class="w-full flex items-baseline justify-between gap-3 text-left"
170 + >
171 + <div>
172 + <p class="font-mono text-sm">{pkg.name}</p>
173 + <p :if={pkg.latest_version} class="text-xs opacity-60">
174 + latest: <span class="font-mono">{pkg.latest_version}</span>
175 + </p>
173 176 </div>
174 − <code
175 − :if={install_line(format, @registry, pkg, v.version)}
176 − class="block bg-base-200 rounded px-2 py-1 select-all text-[11px] overflow-x-auto"
177 − >
178 − {install_line(format, @registry, pkg, v.version)}
179 − </code>
180 − </li>
181 − <li :if={Map.get(@versions_by_id, pkg.id, []) == []} class="opacity-60">
182 − No versions.
183 − </li>
184 − </ul>
185 − </li>
186 − </ul>
177 + <.icon
178 + name={if @expanded_id == pkg.id, do: "hero-chevron-up", else: "hero-chevron-down"}
179 + class="size-4 opacity-50"
180 + />
181 + </button>
182 +
183 + <ul
184 + :if={@expanded_id == pkg.id}
185 + class="mt-2 ml-4 border-l border-base-300 pl-3 space-y-2 text-xs"
186 + >
187 + <li :for={v <- Map.get(@versions_by_id, pkg.id, [])} class="space-y-1">
188 + <div class="flex items-baseline justify-between gap-2">
189 + <span class="font-mono">
190 + {v.version}
191 + <span :if={v.yanked} class="badge badge-xs badge-warning ml-1">yanked</span>
192 + </span>
193 + <span :if={v.digest} class="opacity-60 truncate">{short_digest(v.digest)}</span>
194 + <span class="opacity-50">{format_dt(v.pushed_at)}</span>
195 + </div>
196 + <code
197 + :if={install_line(format, @usage_ctx, pkg, v.version)}
198 + class="block bg-base-200 rounded px-2 py-1 select-all text-[11px] overflow-x-auto"
199 + >
200 + {install_line(format, @usage_ctx, pkg, v.version)}
201 + </code>
202 + </li>
203 + <li :if={Map.get(@versions_by_id, pkg.id, []) == []} class="opacity-60">
204 + No versions.
205 + </li>
206 + </ul>
207 + </li>
208 + </ul>
209 + </section>
210 + </div>
211 +
212 + <section class="space-y-3">
213 + <header>
214 + <h2 class="text-sm font-semibold">Using these registries</h2>
215 + <p class="text-xs opacity-70 mt-1">
216 + Pick a format, then whether you're working from a terminal or from
217 + a workflow in this repository.
218 + </p>
219 + </header>
220 +
221 + <nav class="flex flex-wrap gap-1" aria-label="Registry format">
222 + <button
223 + :for={format <- @format_order}
224 + type="button"
225 + phx-click="help-format"
226 + phx-value-format={format}
227 + aria-pressed={to_string(@help_format == format)}
228 + class={[
229 + "px-2.5 py-1 text-xs rounded border transition-colors",
230 + if(@help_format == format,
231 + do: "border-base-content/40 bg-base-200 font-medium",
232 + else: "border-base-300 opacity-70 hover:opacity-100"
233 + )
234 + ]}
235 + >
236 + {format_label(format)}
237 + </button>
238 + </nav>
239 +
240 + <div class="border border-base-300 rounded">
241 + <div class="px-3 pt-3 space-y-3">
242 + <p :if={Usage.summary(@help_format)} class="text-xs opacity-70">
243 + {Usage.summary(@help_format)}
244 + </p>
245 +
246 + <nav class="flex gap-1" aria-label="Audience">
247 + <button
248 + :for={{facet, label} <- @facets}
249 + type="button"
250 + phx-click="help-facet"
251 + phx-value-facet={facet}
252 + aria-pressed={to_string(@help_facet == facet)}
253 + class={[
254 + "px-2.5 py-1 text-xs rounded-t border-b-2 transition-colors",
255 + if(@help_facet == facet,
256 + do: "border-base-content/60 font-medium",
257 + else: "border-transparent opacity-60 hover:opacity-100"
258 + )
259 + ]}
260 + >
261 + {label}
262 + </button>
263 + </nav>
264 + </div>
265 +
266 + <.usage_panel
267 + usage={Usage.for_format(@help_format, @usage_ctx)}
268 + facet={@help_facet}
269 + format={@help_format}
270 + handle={@handle}
271 + repo_name={@repo.name}
272 + />
273 + </div>
187 274 </section>
188 275 </div>
189 276 </Layouts.app>
190 277 """
191 278 end
192 279
193 − defp format_label(format), do: Map.get(@format_labels, format, format)
280 + attr :usage, :map, default: nil
281 + attr :facet, :string, required: true
282 + attr :format, :string, required: true
283 + attr :handle, :string, required: true
284 + attr :repo_name, :string, required: true
285 +
286 + # The OCI instructions need a registry host to name, and there isn't
287 + # one configured on every deployment. Say so rather than rendering
288 + # half a command.
289 + defp usage_panel(%{usage: nil} = assigns) do
290 + ~H"""
291 + <p class="px-3 py-6 text-xs opacity-60">
292 + <span :if={@format == "oci"}>
293 + No container registry host is configured for this forge
294 + (<code class="font-mono">:registry_host</code>), so there's no pull
295 + reference to show.
296 + </span>
297 + <span :if={@format != "oci"}>No instructions for this format.</span>
298 + </p>
299 + """
300 + end
194 301
195 − # The line that points a client at this repository's registry. OCI
196 − # has none — the pull reference carries the host itself.
197 − defp configure_line("pypi", %{handle: handle, repo_name: name, root: root}),
198 − do: "pip config set global.index-url #{root}/pkg/pypi/#{handle}/#{name}/simple/"
199 −
200 − # Hex verifies the registry signature against a pinned public key and
201 − # refuses a payload whose repository name doesn't match the one it
202 − # was added under — so both halves of this line are load-bearing.
203 − defp configure_line("hex", %{handle: handle, repo_name: name, root: root, hex_repo: hex_repo}) do
204 − url = "#{root}/pkg/hex/#{handle}/#{name}"
205 − "mix hex.repo add #{hex_repo} #{url} --public-key=<(curl -s #{url}/public_key)"
302 + defp usage_panel(assigns) do
303 + ~H"""
304 + <div class="p-3 space-y-4">
305 + <%= if @facet == "ci" do %>
306 + <p class="text-xs opacity-70">
307 + <%!-- Written as a string: bare `{…}` is HEEx interpolation. --%>
308 + <code class="font-mono">{"${{ github.token }}"}</code>
309 + is minted per job and may publish to this repository's registries and
310 + nothing else — so there's no secret to create first. It's the same
311 + token the container registry already accepts.
312 + </p>
313 + <.usage_steps steps={@usage.ci} />
314 + <% else %>
315 + <.usage_group title="Install" steps={@usage.consume} />
316 + <.usage_group title="Publish" steps={@usage.publish} />
317 + <p class="text-xs opacity-60">
318 + <code class="font-mono">$GITGUD_TOKEN</code>
319 + is a registry token for this repository — create one under <.link
320 + navigate={~p"/r/#{@handle}/#{@repo_name}/settings/registry-tokens"}
321 + class="underline underline-offset-2"
322 + >
323 + Settings → Registry tokens
324 + </.link>. Your forge password or a
325 + <code class="font-mono">ggp_</code>
326 + API token works too.
327 + </p>
328 + <% end %>
329 + </div>
330 + """
206 331 end
207 332
208 − # Sparse, not git — the default since Rust 1.68 and a plain tree of
209 − # files over HTTP.
210 − defp configure_line("cargo", %{handle: handle, repo_name: name, root: root}) do
211 − ~s([registries.#{name}] index = "sparse+#{root}/pkg/cargo/#{handle}/#{name}/")
333 + attr :title, :string, required: true
334 + attr :steps, :list, default: []
335 +
336 + defp usage_group(assigns) do
337 + ~H"""
338 + <div :if={@steps != []} class="space-y-2">
339 + <h3 class="text-xs font-semibold uppercase tracking-wide opacity-50">{@title}</h3>
340 + <.usage_steps steps={@steps} />
341 + </div>
342 + """
212 343 end
213 344
214 − # Two lines, because apt needs the key pinned before it will look at
215 − # the repository at all.
216 − defp configure_line("deb", %{handle: handle, repo_name: name, root: root}) do
217 − url = "#{root}/pkg/deb/#{handle}/#{name}"
345 + attr :steps, :list, default: []
218 346
219 − "curl -fsSL #{url}/key.asc | sudo tee /etc/apt/keyrings/#{name}.asc >/dev/null && " <>
220 − ~s(echo "deb [signed-by=/etc/apt/keyrings/#{name}.asc] #{url} stable main" ) <>
221 − "| sudo tee /etc/apt/sources.list.d/#{name}.list"
347 + defp usage_steps(assigns) do
348 + ~H"""
349 + <div :for={step <- @steps} class="space-y-1">
350 + <p class="text-xs opacity-70">{step.label}</p>
351 + <pre class="bg-base-200 rounded px-2.5 py-2 overflow-x-auto"><code
352 + class="text-[11px] leading-relaxed select-all whitespace-pre"
353 + >{step.code}</code></pre>
354 + </div>
355 + """
222 356 end
223 357
224 − defp configure_line(_format, _registry), do: nil
358 + defp format_label(format), do: Map.get(@format_labels, format, format)
225 359
226 − # Reconstruct the zot repository path the webhook deflated: a package
227 − # named after the repo was pushed as `<owner>/<repo>`; a sub-image as
228 − # `<owner>/<repo>/<image>`. (See PackagesWebhookController.)
229 − defp install_line("oci", %{handle: handle, repo_name: name, host: host}, pkg, version)
230 − when is_binary(host) do
231 − base = if pkg.name == name, do: "#{handle}/#{name}", else: "#{handle}/#{name}/#{pkg.name}"
360 + # The one-liner that installs a specific version, shown inline with
361 + # that version. The full instructions live in the panel below.
362 + defp install_line("oci", %{oci_host: host} = ctx, pkg, version) when is_binary(host) do
363 + base =
364 + if pkg.name == ctx.repo_name,
365 + do: "#{ctx.handle}/#{ctx.repo_name}",
366 + else: "#{ctx.handle}/#{ctx.repo_name}/#{pkg.name}"
232 367
233 368 "podman pull #{host}/#{base}:#{version}"
234 369 end
235 370
236 − defp install_line("pypi", %{handle: handle, repo_name: name, root: root}, pkg, version) do
237 − "pip install --index-url #{root}/pkg/pypi/#{handle}/#{name}/simple/ #{pkg.name}==#{version}"
371 + defp install_line("pypi", ctx, pkg, version) do
372 + "pip install --index-url #{Usage.base_url(ctx, "pypi")}/simple/ #{pkg.name}==#{version}"
238 373 end
239 374
240 − defp install_line("hex", %{hex_repo: hex_repo}, pkg, version) do
241 − ~s[{:#{pkg.name}, "~> #{version}", repo: "#{hex_repo}"}]
375 + defp install_line("hex", ctx, pkg, version) do
376 + ~s[{:#{pkg.name}, "~> #{version}", repo: "#{ctx.hex_repo}"}]
242 377 end
243 378
244 − defp install_line("cargo", %{repo_name: name}, pkg, version) do
245 − "cargo add #{pkg.name}@#{version} --registry #{name}"
379 + defp install_line("cargo", ctx, pkg, version) do
380 + "cargo add #{pkg.name}@#{version} --registry #{ctx.repo_name}"
246 381 end
247 382
248 − defp install_line("deb", _registry, pkg, version) do
383 + defp install_line("deb", _ctx, pkg, version) do
249 384 "sudo apt-get install #{pkg.name}=#{version}"
250 385 end
251 386
252 − defp install_line(_format, _registry, _pkg, _version), do: nil
387 + defp install_line(_format, _ctx, _pkg, _version), do: nil
253 388
254 389 defp short_digest("sha256:" <> rest),
255 390 do: "sha256:" <> binary_part(rest, 0, min(12, byte_size(rest)))
modified lib/git_gud_web/router.ex
+5 −0
@@ -148,6 +148,11 @@ defmodule GitGudWeb.Router do
148 148 scope "/pkg", GitGudWeb do
149 149 pipe_through :raw
150 150
151 + # Signing keys belong to the namespace, not the repository, so
152 + # there is one URL to pin per organization. Declared before the
153 + # generic route, which would otherwise read `_keys` as a format.
154 + get "/_keys/:format/:owner", PackageRegistryController, :owner_key
155 +
151 156 get "/:format/:owner/:name/*rest", PackageRegistryController, :index
152 157 head "/:format/:owner/:name/*rest", PackageRegistryController, :index
153 158 post "/:format/:owner/:name/*rest", PackageRegistryController, :store
added priv/repo/migrations/20260911020000_scope_signing_keys_to_namespace.exs
+101 −0
@@ -0,0 +1,101 @@
1 +defmodule GitGud.Repo.Migrations.ScopeSigningKeysToNamespace do
2 + use Ecto.Migration
3 +
4 + @moduledoc """
5 + Move package signing keys from the repository to the namespace that
6 + owns it — the organization, or the user for a personal repository.
7 +
8 + Per-repository keys were wrong in the way that only shows up on the
9 + consuming end. A machine installing from five repositories in one
10 + organization needed five entries in `/etc/apt/keyrings` and five
11 + `signed-by=` clauses, for what is, to anyone reading the sources
12 + list, one publisher. The key is a *trust anchor for a publisher*,
13 + and the publisher is the namespace.
14 +
15 + One key per namespace per format, so `organization_id` and `user_id`
16 + are both nullable and exactly one is set — enforced by a check
17 + constraint rather than by convention, because a row with neither
18 + would be a key nothing can ever find again.
19 +
20 + Existing rows are folded onto their namespace, oldest first. Where
21 + two repositories in the same namespace had each generated a key, the
22 + younger one loses: the older has had more time to be pinned by a
23 + client, and breaking the newer pin is the smaller harm.
24 + """
25 +
26 + def up do
27 + alter table(:package_signing_keys) do
28 + add :organization_id, references(:organizations, on_delete: :delete_all)
29 + add :user_id, references(:users, on_delete: :delete_all)
30 + end
31 +
32 + execute """
33 + UPDATE package_signing_keys k
34 + SET organization_id = r.organization_id,
35 + user_id = CASE WHEN r.organization_id IS NULL THEN r.owner_id END
36 + FROM repositories r
37 + WHERE r.id = k.repository_id
38 + """
39 +
40 + # Fold duplicates onto one key per namespace, keeping the oldest.
41 + execute """
42 + DELETE FROM package_signing_keys k
43 + USING package_signing_keys other
44 + WHERE k.format = other.format
45 + AND k.id > other.id
46 + AND (
47 + (k.organization_id IS NOT NULL AND k.organization_id = other.organization_id)
48 + OR (k.user_id IS NOT NULL AND k.user_id = other.user_id)
49 + )
50 + """
51 +
52 + # Anything that couldn't be attributed to a namespace has no way to
53 + # be looked up again; it is not a key, it is a leak.
54 + execute "DELETE FROM package_signing_keys WHERE organization_id IS NULL AND user_id IS NULL"
55 +
56 + drop unique_index(:package_signing_keys, [:repository_id, :format])
57 +
58 + alter table(:package_signing_keys) do
59 + remove :repository_id
60 + end
61 +
62 + create unique_index(:package_signing_keys, [:organization_id, :format],
63 + where: "organization_id IS NOT NULL",
64 + name: :package_signing_keys_organization_format_index
65 + )
66 +
67 + create unique_index(:package_signing_keys, [:user_id, :format],
68 + where: "user_id IS NOT NULL",
69 + name: :package_signing_keys_user_format_index
70 + )
71 +
72 + create constraint(:package_signing_keys, :package_signing_keys_one_owner,
73 + check: "(organization_id IS NULL) <> (user_id IS NULL)"
74 + )
75 + end
76 +
77 + def down do
78 + drop constraint(:package_signing_keys, :package_signing_keys_one_owner)
79 +
80 + drop index(:package_signing_keys, [:user_id, :format],
81 + name: :package_signing_keys_user_format_index
82 + )
83 +
84 + drop index(:package_signing_keys, [:organization_id, :format],
85 + name: :package_signing_keys_organization_format_index
86 + )
87 +
88 + # There is no faithful way back: one namespace key stood in for
89 + # every repository under it, and which repository should now own it
90 + # is not recoverable. Start clean rather than guess.
91 + execute "DELETE FROM package_signing_keys"
92 +
93 + alter table(:package_signing_keys) do
94 + add :repository_id, references(:repositories, on_delete: :delete_all), null: false
95 + remove :organization_id
96 + remove :user_id
97 + end
98 +
99 + create unique_index(:package_signing_keys, [:repository_id, :format])
100 + end
101 +end
added test/git_gud/package_signing_keys_test.exs
+174 −0
@@ -0,0 +1,174 @@
1 +defmodule GitGud.PackageSigningKeysTest do
2 + @moduledoc """
3 + Signing keys belong to the namespace that publishes, not to the
4 + repository. The whole point is what a *consumer* has to do: one
5 + keyring entry per organization, not one per repository.
6 + """
7 + use GitGudWeb.ConnCase, async: false
8 +
9 + alias GitGud.Packages
10 + alias GitGud.Packages.Apt.Native
11 + alias GitGud.Repositories.Storage
12 +
13 + import GitGud.AccountsFixtures
14 + import GitGud.ForgeFixtures
15 +
16 + describe "scoping" do
17 + test "two repositories in one organization share a key" do
18 + {_user, org, one, two} = org_with_two_repos()
19 +
20 + assert {:ok, %{public_pem: first}} = Packages.signing_key(one, "deb")
21 + assert {:ok, %{public_pem: second}} = Packages.signing_key(two, "deb")
22 +
23 + assert first == second
24 + assert Packages.get_signing_key(org, "deb")
25 + end
26 +
27 + test "and the apt repositories they serve verify against that one key" do
28 + {_user, _org, one, two} = org_with_two_repos()
29 +
30 + key = Packages.signing_key(one, "deb") |> elem(1) |> Map.fetch!(:public_pem)
31 +
32 + for repo <- [one, two] do
33 + root = "/pkg/deb/#{Storage.repo_handle(repo)}/#{repo.name}"
34 +
35 + release = get(build_conn(), root <> "/dists/stable/Release").resp_body
36 + signature = get(build_conn(), root <> "/dists/stable/Release.gpg").resp_body
37 +
38 + assert {:ok, true} = Native.verify_detached(key, release, signature),
39 + "#{repo.name}'s Release did not verify against the organization key"
40 + end
41 + end
42 +
43 + test "different organizations get different keys" do
44 + {_u1, _org1, one, _} = org_with_two_repos()
45 + {_u2, _org2, two, _} = org_with_two_repos()
46 +
47 + assert {:ok, %{public_pem: first}} = Packages.signing_key(one, "deb")
48 + assert {:ok, %{public_pem: second}} = Packages.signing_key(two, "deb")
49 +
50 + refute first == second
51 + end
52 +
53 + test "a personal repository's key belongs to its user, and is shared too" do
54 + user = user_fixture()
55 + {_u, one} = repository_fixture(%{owner: user})
56 + {_u, two} = repository_fixture(%{owner: user})
57 +
58 + assert {:ok, %{public_pem: first}} = Packages.signing_key(one, "hex")
59 + assert {:ok, %{public_pem: second}} = Packages.signing_key(two, "hex")
60 +
61 + assert first == second
62 + assert Packages.get_signing_key(user, "hex")
63 + end
64 +
65 + test "formats don't share a key — apt wants OpenPGP, Hex wants RSA" do
66 + {_user, _org, repo, _} = org_with_two_repos()
67 +
68 + {:ok, %{public_pem: deb}} = Packages.signing_key(repo, "deb")
69 + {:ok, %{public_pem: hex}} = Packages.signing_key(repo, "hex")
70 +
71 + assert deb =~ "-----BEGIN PGP PUBLIC KEY BLOCK-----"
72 + assert hex =~ "-----BEGIN PUBLIC KEY-----"
73 + end
74 +
75 + test "a key is generated once and then reused verbatim" do
76 + {_user, _org, repo, _} = org_with_two_repos()
77 +
78 + assert {:ok, %{private_pem: first}} = Packages.signing_key(repo, "deb")
79 + assert {:ok, %{private_pem: second}} = Packages.signing_key(repo, "deb")
80 +
81 + assert first == second
82 + end
83 +
84 + test "get_signing_key/2 never generates" do
85 + {_user, org, _one, _two} = org_with_two_repos()
86 +
87 + assert Packages.get_signing_key(org, "deb") == nil
88 + end
89 + end
90 +
91 + describe "GET /pkg/_keys/:format/:owner" do
92 + test "serves the namespace key once it exists", %{conn: conn} do
93 + {_user, org, repo, _two} = org_with_two_repos()
94 + {:ok, %{public_pem: key}} = Packages.signing_key(repo, "deb")
95 +
96 + conn = get(conn, "/pkg/_keys/deb/#{org.handle}")
97 +
98 + assert conn.status == 200
99 + assert conn.resp_body == key
100 + assert get_resp_header(conn, "content-type") == ["application/pgp-keys"]
101 + end
102 +
103 + test "and it is the same key every repository in the org signs with", %{conn: conn} do
104 + {_user, org, one, two} = org_with_two_repos()
105 + {:ok, _} = Packages.signing_key(one, "deb")
106 +
107 + shared = get(conn, "/pkg/_keys/deb/#{org.handle}").resp_body
108 +
109 + for repo <- [one, two] do
110 + root = "/pkg/deb/#{Storage.repo_handle(repo)}/#{repo.name}"
111 + assert get(build_conn(), root <> "/key.asc").resp_body == shared
112 + end
113 + end
114 +
115 + test "404s for a namespace that has never published — not an oracle", %{conn: conn} do
116 + {_user, org, _one, _two} = org_with_two_repos()
117 +
118 + assert get(conn, "/pkg/_keys/deb/#{org.handle}").status == 404
119 + assert get(build_conn(), "/pkg/_keys/deb/nobody-at-all").status == 404
120 + end
121 +
122 + test "404s for a format the forge doesn't serve", %{conn: conn} do
123 + {_user, org, repo, _two} = org_with_two_repos()
124 + {:ok, _} = Packages.signing_key(repo, "deb")
125 +
126 + assert get(conn, "/pkg/_keys/nonsense/#{org.handle}").status == 404
127 + end
128 +
129 + test "needs no credentials — apt and mix pin the key before they have any", %{conn: conn} do
130 + {_user, org, repo, _two} = org_with_two_repos()
131 + {:ok, _} = Packages.signing_key(repo, "deb")
132 +
133 + assert get(conn, "/pkg/_keys/deb/#{org.handle}").status == 200
134 + end
135 +
136 + test "serves a Hex public key as text", %{conn: conn} do
137 + {_user, org, repo, _two} = org_with_two_repos()
138 + {:ok, _} = Packages.signing_key(repo, "hex")
139 +
140 + conn = get(conn, "/pkg/_keys/hex/#{org.handle}")
141 +
142 + assert conn.status == 200
143 + assert conn.resp_body =~ "-----BEGIN PUBLIC KEY-----"
144 + end
145 + end
146 +
147 + defp org_with_two_repos do
148 + user = user_fixture()
149 +
150 + {:ok, org} =
151 + GitGud.Organizations.create_organization(user, %{
152 + "handle" => "org#{System.unique_integer([:positive])}",
153 + "name" => "Test Org"
154 + })
155 +
156 + {:ok, one} =
157 + GitGud.Repositories.create_repository_for_org(org, user, %{
158 + "name" => unique_repo_name(),
159 + "visibility" => "public"
160 + })
161 +
162 + {:ok, two} =
163 + GitGud.Repositories.create_repository_for_org(org, user, %{
164 + "name" => unique_repo_name(),
165 + "visibility" => "public"
166 + })
167 +
168 + # `Storage.repo_handle/1` refuses to guess, so hand back repos that
169 + # can actually name their own namespace.
170 + preload = &GitGud.Repo.preload(&1, [:owner, :organization])
171 +
172 + {user, org, preload.(one), preload.(two)}
173 + end
174 +end
added test/git_gud_web/package_usage_test.exs
+243 −0
@@ -0,0 +1,243 @@
1 +defmodule GitGudWeb.PackageUsageTest do
2 + @moduledoc """
3 + The two facets the packages page has to document — a package manager
4 + at a terminal, and a workflow in this repository — and the CI auth
5 + that makes the second one a paste rather than a setup task.
6 + """
7 + use GitGudWeb.ConnCase, async: false
8 +
9 + alias GitGud.Packages
10 + alias GitGud.Packages.Auth
11 + alias GitGud.Packages.Usage
12 + alias GitGud.Repositories.Storage
13 + alias GitGud.Workflows.RunnerJwt
14 +
15 + import GitGud.ForgeFixtures
16 + import Phoenix.LiveViewTest
17 +
18 + setup do
19 + {user, repo} = repository_fixture(%{visibility: "public"})
20 + handle = Storage.owner_handle(user)
21 +
22 + {:ok,
23 + user: user,
24 + repo: repo,
25 + handle: handle,
26 + ctx: Usage.context(repo, "registry.example.com"),
27 + path: "/r/#{handle}/#{repo.name}/packages"}
28 + end
29 +
30 + describe "CI publishes with the per-job token" do
31 + setup %{repo: repo} do
32 + {:ok, job_token: job_token(repo)}
33 + end
34 +
35 + test "a job token authenticates as its own repository", %{repo: repo, job_token: token} do
36 + conn = Plug.Test.conn(:get, "/") |> Plug.Conn.put_req_header("authorization", token)
37 +
38 + assert {:job, repo_id} = Auth.authenticate(conn)
39 + assert repo_id == repo.id
40 + end
41 +
42 + test "and may publish there with no registry token in sight", %{
43 + conn: conn,
44 + repo: repo,
45 + handle: handle,
46 + job_token: token
47 + } do
48 + root = "/pkg/cargo/#{handle}/#{repo.name}"
49 + json = Jason.encode!(%{"name" => "widget", "vers" => "1.0.0", "deps" => []})
50 +
51 + body =
52 + <<byte_size(json)::little-unsigned-32, json::binary,
53 + byte_size("crate")::little-unsigned-32, "crate">>
54 +
55 + conn =
56 + conn
57 + |> put_req_header("authorization", token)
58 + |> put_req_header("content-type", "application/json")
59 + |> put(root <> "/api/v1/crates/new", body)
60 +
61 + assert conn.status == 200
62 + assert Packages.get_package(repo, "cargo", "widget")
63 + end
64 +
65 + test "the same token reaches nothing in another repository", %{repo: repo} do
66 + {other_user, other} = repository_fixture(%{visibility: "private"})
67 + principal = {:job, repo.id}
68 +
69 + refute Auth.authorize(principal, other, :write)
70 + refute Auth.authorize(principal, other, :read)
71 +
72 + # …and over HTTP, the same answer.
73 + root = "/pkg/pypi/#{Storage.owner_handle(other_user)}/#{other.name}"
74 +
75 + conn =
76 + build_conn()
77 + |> put_req_header("authorization", job_token(repo))
78 + |> get(root <> "/simple/")
79 +
80 + assert conn.status in [401, 403]
81 + end
82 +
83 + test "an expired or forged job token is just anonymous", %{repo: repo} do
84 + forged = job_token(repo) <> "tampered"
85 +
86 + conn = Plug.Test.conn(:get, "/") |> Plug.Conn.put_req_header("authorization", forged)
87 +
88 + assert Auth.authenticate(conn) == :anonymous
89 + end
90 +
91 + test "a registry token still wins where both would work", %{repo: repo} do
92 + {:ok, _t, plaintext} = Packages.create_registry_token(repo, "ci", ["pull"])
93 +
94 + conn = Plug.Test.conn(:get, "/") |> Plug.Conn.put_req_header("authorization", plaintext)
95 +
96 + assert {:token, _, ["pull"]} = Auth.authenticate(conn)
97 + end
98 + end
99 +
100 + describe "Usage.for_format/2" do
101 + test "every format the forge serves has both facets", %{ctx: ctx} do
102 + for format <- ~w(pypi hex cargo deb oci) do
103 + usage = Usage.for_format(format, ctx)
104 +
105 + assert usage, "#{format} has no instructions"
106 + assert Usage.summary(format)
107 +
108 + # Every step carries a label and something to paste. The type
109 + # checker already knows the lists aren't empty, so checking
110 + # their contents is the assertion with anything left to catch.
111 + for facet <- [:consume, :publish, :ci], step <- Map.fetch!(usage, facet) do
112 + assert String.trim(step.label) != "", "#{format}/#{facet} has an unlabelled step"
113 + assert String.trim(step.code) != "", "#{format}/#{facet} has an empty step"
114 + assert step.language in ~w(shell yaml toml elixir)
115 + end
116 + end
117 + end
118 +
119 + test "every CI snippet uses the per-job token, never a stored secret", %{ctx: ctx} do
120 + for format <- ~w(pypi hex cargo deb oci) do
121 + code = Enum.map_join(Usage.for_format(format, ctx).ci, "\n", & &1.code)
122 +
123 + assert code =~ "github.token", "#{format}'s CI snippet doesn't use github.token"
124 + refute code =~ "secrets.", "#{format}'s CI snippet asks for a stored secret"
125 + end
126 + end
127 +
128 + test "every snippet names this repository's own registry", %{ctx: ctx, repo: repo} do
129 + for format <- ~w(pypi hex cargo deb) do
130 + usage = Usage.for_format(format, ctx)
131 + code = Enum.map_join(usage.consume ++ usage.publish ++ usage.ci, "\n", & &1.code)
132 +
133 + assert code =~ repo.name
134 + assert code =~ Usage.base_url(ctx, format)
135 + end
136 + end
137 +
138 + test "the Hex snippets agree with the name the registry signs", %{ctx: ctx} do
139 + usage = Usage.for_format("hex", ctx)
140 + code = Enum.map_join(usage.consume, "\n", & &1.code)
141 +
142 + # `mix` rejects the registry if these disagree, so the add line
143 + # and the dep line must use the same name.
144 + assert code =~ "mix hex.repo add #{ctx.hex_repo} "
145 + assert code =~ ~s(repo: "#{ctx.hex_repo}")
146 + end
147 +
148 + test "the Cargo CI snippet uses the env var name cargo actually reads" do
149 + {_user, repo} = repository_fixture(%{name: "my-crate-repo"})
150 + ctx = Usage.context(repo)
151 +
152 + code = Enum.map_join(Usage.for_format("cargo", ctx).ci, "\n", & &1.code)
153 +
154 + # registries.my-crate-repo → CARGO_REGISTRIES_MY_CRATE_REPO_TOKEN
155 + assert code =~ "CARGO_REGISTRIES_MY_CRATE_REPO_TOKEN"
156 + end
157 +
158 + test "OCI instructions are withheld when no registry host is configured", %{repo: repo} do
159 + assert Usage.for_format("oci", Usage.context(repo, nil)) == nil
160 + end
161 +
162 + test "an unknown format has nothing to say", %{ctx: ctx} do
163 + assert Usage.for_format("nonsense", ctx) == nil
164 + assert Usage.summary("nonsense") == nil
165 + end
166 + end
167 +
168 + describe "the packages page" do
169 + test "documents both facets without anything published yet", %{conn: conn, path: path} do
170 + {:ok, view, html} = live(conn, path)
171 +
172 + assert html =~ "Nothing published yet"
173 + assert html =~ "Using these registries"
174 + assert html =~ "Package manager"
175 + assert html =~ "CI workflow"
176 +
177 + # Defaults to the package-manager facet.
178 + assert render(view) =~ "twine upload"
179 + end
180 +
181 + test "switching facet shows the workflow snippet", %{conn: conn, path: path} do
182 + {:ok, view, _html} = live(conn, path)
183 +
184 + html = view |> element("button[phx-value-facet=ci]") |> render_click()
185 +
186 + assert html =~ "github.token"
187 + assert html =~ "Publish to the forge"
188 + # The CI pane is about workflows, not terminals.
189 + refute html =~ "GITGUD_TOKEN"
190 + end
191 +
192 + test "switching format shows that format's instructions", %{conn: conn, path: path} do
193 + {:ok, view, _html} = live(conn, path)
194 +
195 + html = view |> element("button[phx-value-format=cargo]") |> render_click()
196 +
197 + assert html =~ "cargo publish --registry"
198 + assert html =~ "sparse+"
199 + refute html =~ "twine upload"
200 + end
201 +
202 + test "every format the forge serves is reachable from the tab strip", %{
203 + conn: conn,
204 + path: path
205 + } do
206 + {:ok, view, _html} = live(conn, path)
207 +
208 + for format <- ~w(oci pypi hex cargo deb) do
209 + assert has_element?(view, "button[phx-value-format=#{format}]"),
210 + "#{format} is not offered in the format tabs"
211 + end
212 + end
213 +
214 + test "points at registry tokens for the terminal case", %{conn: conn, path: path, repo: repo} do
215 + {:ok, _view, html} = live(conn, path)
216 +
217 + assert html =~ "GITGUD_TOKEN"
218 + assert html =~ "/r/"
219 + assert html =~ "#{repo.name}/settings/registry-tokens"
220 + end
221 +
222 + test "opens on a format that has packages", %{conn: conn, repo: repo, path: path} do
223 + {:ok, _p, _v, _f} =
224 + Packages.publish_file(repo, "deb", "widget", "1.0", %{
225 + filename: "widget_1.0_amd64.deb",
226 + body: "deb"
227 + })
228 +
229 + {:ok, _view, html} = live(conn, path)
230 +
231 + # The Debian pane, not the default Python one.
232 + assert html =~ "/etc/apt/keyrings"
233 + refute html =~ "twine upload"
234 + end
235 + end
236 +
237 + defp job_token(repo) do
238 + RunnerJwt.mint(
239 + %GitGud.Workflows.WorkflowJob{id: 1, workflow_run_id: 1},
240 + %GitGud.Workflows.WorkflowRun{repository_id: repo.id}
241 + )
242 + end
243 +end

Parents: 6bfb936