neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Package registries: per-namespace signing keys, and usage docs
aab4e3f · Gabriel Morell · 2026-09-11 15:02
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