neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Let a repository publish named package formats regardless of visibility
439db79 · Gabriel Morell · 2026-09-19 00:28
Message
{commit_body(@commit)}
Files changed
modified
docs/package-registries.md
+32
−0
@@ -28,6 +28,38 @@ cross-mounting, GC, conformance. An apt repository is static files over
| 28 | 28 | plain HTTP. There is no daemon to embed. The only hard part is OpenPGP |
| 29 | 29 | signing, which OTP cannot do. |
| 30 | 30 | |
| 31 | +## Visibility, and publishing out of a repository that has none | |
| 32 | + | |
| 33 | +Packages follow the repository. A private or internal repository serves | |
| 34 | +nothing to an anonymous client and is absent from the namespace-wide | |
| 35 | +`_all` index, which is what `GitGud.Packages.Auth.authorize/3` says and | |
| 36 | +what the registry controller and the packages dashboard both filter | |
| 37 | +through. | |
| 38 | + | |
| 39 | +That is right for a Python package whose source is private. It is wrong | |
| 40 | +for an Android client: the APK is meant to be on a phone, an F-Droid | |
| 41 | +client is anonymous, and the repository holding the code is not going | |
| 42 | +public to make that happen. | |
| 43 | + | |
| 44 | +So a repository can name formats it publishes anyway — | |
| 45 | +`public_package_formats`, set per repository in its settings, empty by | |
| 46 | +default. A format in that list is readable by anyone; everything else | |
| 47 | +is unchanged: | |
| 48 | + | |
| 49 | +* **Reads only.** Publishing still needs a token or the job's own | |
| 50 | + credential. `authorize/4` widens `:read` and delegates every other | |
| 51 | + action to `authorize/3`. | |
| 52 | +* **Only the formats named.** Ticking `fdroid` does not open the `raw` | |
| 53 | + bucket next to it. This is why it is a list and not a boolean: the | |
| 54 | + answer genuinely differs by format. | |
| 55 | +* **Only packages.** Nothing outside the package registry consults it. | |
| 56 | + The repository stays as private as it was, including its source, its | |
| 57 | + issues, and its CI. | |
| 58 | + | |
| 59 | +The namespace `_all` index and the dashboard both filter per format | |
| 60 | +rather than per repository, so a repository that publishes `fdroid` | |
| 61 | +appears in that index and in no other. | |
| 62 | + | |
| 31 | 63 | ## UI surface |
| 32 | 64 | |
| 33 | 65 | One page per repo listing what has been published and is installable — |
modified
lib/git_gud/packages.ex
+26
−4
@@ -454,11 +454,15 @@ defmodule GitGud.Packages do
| 454 | 454 | grouped by format. |
| 455 | 455 | |
| 456 | 456 | This is the `_all` view, not a summary of what exists: it filters |
| 457 | − repositories through `Packages.Auth.authorize/3` exactly as | |
| 457 | + repositories through `Packages.Auth.authorize/4` exactly as | |
| 458 | 458 | `GitGudWeb.PackageRegistryController` does, so the page and the |
| 459 | 459 | endpoint cannot drift into disagreeing. A dashboard that lists a |
| 460 | 460 | package a consumer can't fetch is worse than no dashboard. |
| 461 | 461 | |
| 462 | + The filter is per format, because the answer is: a repository that | |
| 463 | + publishes `fdroid` regardless of its visibility appears in that | |
| 464 | + column and not in the others. | |
| 465 | + | |
| 462 | 466 | Returns a map of format to: |
| 463 | 467 | |
| 464 | 468 | * `:packages` — `%{package:, repository:, versions:}`, sorted |
@@ -469,10 +473,15 @@ defmodule GitGud.Packages do
| 469 | 473 | """ |
| 470 | 474 | @spec namespace_catalog(struct(), term()) :: %{String.t() => map()} |
| 471 | 475 | def namespace_catalog(namespace, principal) do |
| 476 | + # A first pass on what could be visible at all; the per-format | |
| 477 | + # question is asked below, once the packages are grouped. | |
| 472 | 478 | repos = |
| 473 | 479 | namespace |
| 474 | 480 | |> namespace_repositories() |
| 475 | − |> Enum.filter(&GitGud.Packages.Auth.authorize(principal, &1, :read)) | |
| 481 | + |> Enum.filter(fn repo -> | |
| 482 | + GitGud.Packages.Auth.authorize(principal, repo, :read) or | |
| 483 | + repo.public_package_formats != [] | |
| 484 | + end) | |
| 476 | 485 | |
| 477 | 486 | by_id = Map.new(repos, &{&1.id, &1}) |
| 478 | 487 | ids = Map.keys(by_id) |
@@ -494,9 +503,18 @@ defmodule GitGud.Packages do
| 494 | 503 | from(p in Package, where: p.repository_id in ^ids, order_by: [asc: p.name]) |
| 495 | 504 | |> Repo.all() |
| 496 | 505 | |> Enum.group_by(& &1.format) |
| 497 | − |> Map.new(fn {format, packages} -> | |
| 506 | + |> Enum.map(fn {format, packages} -> | |
| 498 | 507 | entries = |
| 499 | − Enum.map(packages, fn package -> | |
| 508 | + packages | |
| 509 | + |> Enum.filter(fn package -> | |
| 510 | + GitGud.Packages.Auth.authorize( | |
| 511 | + principal, | |
| 512 | + Map.fetch!(by_id, package.repository_id), | |
| 513 | + :read, | |
| 514 | + format | |
| 515 | + ) | |
| 516 | + end) | |
| 517 | + |> Enum.map(fn package -> | |
| 500 | 518 | %{ |
| 501 | 519 | package: package, |
| 502 | 520 | repository: Map.fetch!(by_id, package.repository_id), |
@@ -514,6 +532,10 @@ defmodule GitGud.Packages do
| 514 | 532 | |> Enum.sort_by(& &1.name) |
| 515 | 533 | }} |
| 516 | 534 | end) |
| 535 | + # A format whose every package was filtered out is absent, like | |
| 536 | + # one that was never published to. | |
| 537 | + |> Enum.reject(fn {_format, %{packages: packages}} -> packages == [] end) | |
| 538 | + |> Map.new() | |
| 517 | 539 | end |
| 518 | 540 | end |
| 519 | 541 |
modified
lib/git_gud/packages/auth.ex
+32
−0
@@ -128,6 +128,38 @@ defmodule GitGud.Packages.Auth do
| 128 | 128 | end |
| 129 | 129 | end |
| 130 | 130 | |
| 131 | + @doc """ | |
| 132 | + May `principal` perform `action` (`:read` or `:write`) on `repo`, for | |
| 133 | + package format `format`? | |
| 134 | + | |
| 135 | + Same question as `authorize/3`, plus the one exception to "packages | |
| 136 | + follow the repository": a repository may name formats it publishes | |
| 137 | + regardless of its visibility, and for those a read is allowed to | |
| 138 | + anyone. | |
| 139 | + | |
| 140 | + It widens reads only, and only for the named format. A repository | |
| 141 | + publishing `fdroid` has not opened its `raw` bucket, has not opened | |
| 142 | + publishing to anonymous callers, and has not made its source | |
| 143 | + readable — nothing here is consulted outside the package registry. | |
| 144 | + """ | |
| 145 | + @spec authorize(principal(), Repository.t(), :read | :write, String.t() | nil) :: boolean() | |
| 146 | + def authorize(principal, %Repository{} = repo, :read, format) when is_binary(format) do | |
| 147 | + authorize(principal, repo, :read) or public_format?(repo, format) | |
| 148 | + end | |
| 149 | + | |
| 150 | + def authorize(principal, %Repository{} = repo, action, _format), | |
| 151 | + do: authorize(principal, repo, action) | |
| 152 | + | |
| 153 | + @doc """ | |
| 154 | + Does `repo` publish `format` regardless of its visibility? | |
| 155 | + """ | |
| 156 | + @spec public_format?(Repository.t(), String.t()) :: boolean() | |
| 157 | + def public_format?(%Repository{public_package_formats: formats}, format) | |
| 158 | + when is_list(formats), | |
| 159 | + do: format in formats | |
| 160 | + | |
| 161 | + def public_format?(%Repository{}, _format), do: false | |
| 162 | + | |
| 131 | 163 | @doc """ |
| 132 | 164 | May `principal` perform `action` (`:read` or `:write`) on `repo`? |
| 133 | 165 |
modified
lib/git_gud/repositories/repository.ex
+14
−1
@@ -16,6 +16,9 @@ defmodule GitGud.Repositories.Repository do
| 16 | 16 | field :default_branch, :string, default: "main" |
| 17 | 17 | field :visibility, :string, default: "private" |
| 18 | 18 | field :interaction_policy, :string, default: "public" |
| 19 | + # Package formats published regardless of `visibility`. Empty means | |
| 20 | + # packages follow visibility, which is the historical behaviour. | |
| 21 | + field :public_package_formats, {:array, :string}, default: [] | |
| 19 | 22 | field :disk_path, :string |
| 20 | 23 | field :pushed_at, :utc_datetime |
| 21 | 24 | field :size_kb, :integer, default: 0 |
@@ -86,8 +89,18 @@ defmodule GitGud.Repositories.Repository do
| 86 | 89 | :required_approvals, |
| 87 | 90 | :block_self_approval, |
| 88 | 91 | :upstream_sync_mode, |
| 89 | − :upstream_auto_sync | |
| 92 | + :upstream_auto_sync, | |
| 93 | + :public_package_formats | |
| 90 | 94 | ]) |
| 95 | + # The form posts a blank entry so that clearing every box still | |
| 96 | + # sends the key; without dropping it, "none" would persist as [""]. | |
| 97 | + |> update_change(:public_package_formats, fn formats -> | |
| 98 | + formats | |
| 99 | + |> Enum.reject(&(is_nil(&1) or &1 == "")) | |
| 100 | + |> Enum.uniq() | |
| 101 | + |> Enum.sort() | |
| 102 | + end) | |
| 103 | + |> validate_subset(:public_package_formats, GitGud.Packages.Format.formats()) | |
| 91 | 104 | |> validate_inclusion(:visibility, @valid_visibilities) |
| 92 | 105 | |> validate_inclusion(:upstream_sync_mode, @valid_sync_modes) |
| 93 | 106 | |> validate_inclusion(:interaction_policy, @valid_interaction_policies) |
modified
lib/git_gud_web/controllers/package_registry_controller.ex
+5
−3
@@ -109,11 +109,13 @@ defmodule GitGudWeb.PackageRegistryController do
| 109 | 109 | # Filtered here, once, so the adapter renders what it is given |
| 110 | 110 | # and makes no visibility decisions of its own. An anonymous |
| 111 | 111 | # caller gets the public repositories; a token gets its own plus |
| 112 | − # those; a user gets what they can read. | |
| 112 | + # those; a user gets what they can read. The format is passed so | |
| 113 | + # a repository that publishes this format regardless of its | |
| 114 | + # visibility is in the index it opted into, and no other. | |
| 113 | 115 | repos = |
| 114 | 116 | namespace |
| 115 | 117 | |> Packages.namespace_repositories() |
| 116 | − |> Enum.filter(&Auth.authorize(principal, &1, :read)) | |
| 118 | + |> Enum.filter(&Auth.authorize(principal, &1, :read, format)) | |
| 117 | 119 | |
| 118 | 120 | ctx = %{ |
| 119 | 121 | path: List.wrap(params["rest"]), |
@@ -149,7 +151,7 @@ defmodule GitGudWeb.PackageRegistryController do
| 149 | 151 | with {:ok, mod} <- adapter(format), |
| 150 | 152 | {:ok, repo} <- fetch_repo(owner, name), |
| 151 | 153 | principal = Auth.authenticate(conn), |
| 152 | − true <- Auth.authorize(principal, repo, action) do | |
| 154 | + true <- Auth.authorize(principal, repo, action, format) do | |
| 153 | 155 | ctx = %{ |
| 154 | 156 | path: List.wrap(params["rest"]), |
| 155 | 157 | conn: conn, |
modified
lib/git_gud_web/live/repo_live/settings.ex
+27
−0
@@ -345,6 +345,33 @@ defmodule GitGudWeb.RepoLive.Settings do
| 345 | 345 | {"Private — only collaborators", "private"} |
| 346 | 346 | ]} |
| 347 | 347 | /> |
| 348 | + <div class="flex flex-col gap-1"> | |
| 349 | + <span class="text-sm opacity-70">Publish these package formats anyway</span> | |
| 350 | + <%!-- The blank entry keeps the key in the params when every | |
| 351 | + box is cleared; the changeset drops it. --%> | |
| 352 | + <input type="hidden" name="repository[public_package_formats][]" value="" /> | |
| 353 | + <div class="flex flex-wrap gap-x-4 gap-y-1"> | |
| 354 | + <label :for={fmt <- GitGud.Packages.Format.formats()} class="flex items-center gap-2 cursor-pointer"> | |
| 355 | + <input | |
| 356 | + type="checkbox" | |
| 357 | + name="repository[public_package_formats][]" | |
| 358 | + value={fmt} | |
| 359 | + checked={fmt in (@form[:public_package_formats].value || [])} | |
| 360 | + class="checkbox checkbox-sm" | |
| 361 | + /> | |
| 362 | + <span class="text-sm">{fmt}</span> | |
| 363 | + </label> | |
| 364 | + </div> | |
| 365 | + <p class="text-xs opacity-60"> | |
| 366 | + Packages normally follow the repository's visibility, so a private or | |
| 367 | + internal repo serves none to an anonymous client and is absent from | |
| 368 | + the <code>_all</code> index a phone or an <code>apt</code> host | |
| 369 | + subscribes to. Ticking a format publishes that one regardless — the | |
| 370 | + built artifact goes out, the source stays as private as it was. | |
| 371 | + Reads only, and only the formats ticked: publishing still needs a | |
| 372 | + token, and nothing here affects access to the code. | |
| 373 | + </p> | |
| 374 | + </div> | |
| 348 | 375 | <.input field={@form[:default_branch]} type="text" label="Default branch" /> |
| 349 | 376 | <.input |
| 350 | 377 | field={@form[:block_merge_on_changes_requested]} |
added
priv/repo/migrations/20260918120000_add_public_package_formats.exs
+29
−0
@@ -0,0 +1,29 @@
| 1 | +defmodule GitGud.Repo.Migrations.AddPublicPackageFormats do | |
| 2 | + use Ecto.Migration | |
| 3 | + | |
| 4 | + @moduledoc """ | |
| 5 | + Per-format publishing for repositories that are not themselves public. | |
| 6 | + | |
| 7 | + A private or internal repository's packages follow its visibility, so | |
| 8 | + an anonymous client — an F-Droid phone, an `apt` host, a `pip` in | |
| 9 | + someone else's CI — gets nothing from it and it is absent from the | |
| 10 | + namespace-wide `_all` index. That is right for source, and wrong for | |
| 11 | + the case where the build artifact is meant to be public and the code | |
| 12 | + is not. | |
| 13 | + | |
| 14 | + This names the formats that are published anyway. Empty means "follow | |
| 15 | + visibility", which is what every existing repository gets, so the | |
| 16 | + default is the behaviour that was there before. | |
| 17 | + | |
| 18 | + Per format rather than a single boolean because the answer differs by | |
| 19 | + format: an Android client belongs on a phone, while the `raw` bucket | |
| 20 | + next to it may hold exactly the thing that should stay behind the | |
| 21 | + login. | |
| 22 | + """ | |
| 23 | + | |
| 24 | + def change do | |
| 25 | + alter table(:repositories) do | |
| 26 | + add :public_package_formats, {:array, :string}, null: false, default: [] | |
| 27 | + end | |
| 28 | + end | |
| 29 | +end |
added
test/git_gud_web/public_package_formats_test.exs
+144
−0
@@ -0,0 +1,144 @@
| 1 | +defmodule GitGudWeb.PublicPackageFormatsTest do | |
| 2 | + @moduledoc """ | |
| 3 | + Publishing a format from a repository that is not itself public. | |
| 4 | + | |
| 5 | + The case this exists for: an Android client whose source is private. | |
| 6 | + The APK is meant to be on a phone, and an F-Droid client is anonymous, | |
| 7 | + so without an opt-in the repository is absent from the `_all` index | |
| 8 | + the phone subscribes to and 401s on its own URL. | |
| 9 | + | |
| 10 | + What is asserted here is mostly what the opt-in does *not* do — | |
| 11 | + it widens anonymous reads of the named format and nothing else. | |
| 12 | + """ | |
| 13 | + use GitGudWeb.ConnCase, async: false | |
| 14 | + | |
| 15 | + alias GitGud.Packages | |
| 16 | + alias GitGud.Packages.Auth | |
| 17 | + alias GitGud.Repositories | |
| 18 | + alias GitGud.Repositories.Storage | |
| 19 | + | |
| 20 | + import GitGud.ForgeFixtures | |
| 21 | + | |
| 22 | + @firmware <<0xE9, 0x06, 0x02, 0x1F>> <> :crypto.strong_rand_bytes(256) | |
| 23 | + | |
| 24 | + setup do | |
| 25 | + {user, repo} = repository_fixture(%{visibility: "private"}) | |
| 26 | + {:ok, _t, token} = Packages.create_registry_token(repo, "ci", ["pull", "push"]) | |
| 27 | + handle = Storage.owner_handle(user) | |
| 28 | + | |
| 29 | + # Publish with the token, which works regardless of visibility -- | |
| 30 | + # that is the CI path, and it is not what any of this changes. | |
| 31 | + build_conn() | |
| 32 | + |> put_req_header("authorization", token) | |
| 33 | + |> put_req_header("content-type", "application/octet-stream") | |
| 34 | + |> put("/pkg/raw/#{handle}/#{repo.name}/1.0.0/firmware.bin", @firmware) | |
| 35 | + | |
| 36 | + {:ok, | |
| 37 | + user: user, | |
| 38 | + repo: repo, | |
| 39 | + token: token, | |
| 40 | + handle: handle, | |
| 41 | + root: "/pkg/raw/#{handle}/#{repo.name}"} | |
| 42 | + end | |
| 43 | + | |
| 44 | + defp publish_formats(repo, formats) do | |
| 45 | + {:ok, repo} = Repositories.update_repository(repo, %{"public_package_formats" => formats}) | |
| 46 | + repo | |
| 47 | + end | |
| 48 | + | |
| 49 | + describe "without the opt-in" do | |
| 50 | + test "a private repository's registry refuses an anonymous read", %{conn: conn, root: root} do | |
| 51 | + assert conn |> get(root <> "/manifest.json") |> Map.fetch!(:status) == 401 | |
| 52 | + end | |
| 53 | + | |
| 54 | + test "and it is absent from what a namespace-wide index would serve", %{user: user} do | |
| 55 | + assert Packages.namespace_catalog(user, :anonymous) == %{} | |
| 56 | + end | |
| 57 | + end | |
| 58 | + | |
| 59 | + describe "with the format opted in" do | |
| 60 | + setup %{repo: repo}, do: {:ok, repo: publish_formats(repo, ["raw"])} | |
| 61 | + | |
| 62 | + test "an anonymous client reads it", %{conn: conn, root: root} do | |
| 63 | + conn = get(conn, root <> "/manifest.json") | |
| 64 | + assert conn.status == 200 | |
| 65 | + assert Jason.decode!(conn.resp_body)["latest"] == "1.0.0" | |
| 66 | + end | |
| 67 | + | |
| 68 | + test "the bytes themselves come back", %{conn: conn, root: root} do | |
| 69 | + conn = get(conn, root <> "/1.0.0/firmware.bin") | |
| 70 | + assert conn.status == 200 | |
| 71 | + assert conn.resp_body == @firmware | |
| 72 | + end | |
| 73 | + | |
| 74 | + test "and it appears in what a namespace-wide index would serve", %{user: user, repo: repo} do | |
| 75 | + catalog = Packages.namespace_catalog(user, :anonymous) | |
| 76 | + assert [%{repository: %{id: id}}] = catalog["raw"].packages | |
| 77 | + assert id == repo.id | |
| 78 | + end | |
| 79 | + | |
| 80 | + test "the repository itself is still private", %{repo: repo} do | |
| 81 | + assert Repositories.get_repository!(repo.id).visibility == "private" | |
| 82 | + end | |
| 83 | + end | |
| 84 | + | |
| 85 | + describe "what the opt-in does not open" do | |
| 86 | + setup %{repo: repo}, do: {:ok, repo: publish_formats(repo, ["fdroid"])} | |
| 87 | + | |
| 88 | + test "a format that was not ticked stays shut", %{conn: conn, root: root} do | |
| 89 | + assert conn |> get(root <> "/manifest.json") |> Map.fetch!(:status) == 401 | |
| 90 | + end | |
| 91 | + | |
| 92 | + test "nor does it let an anonymous caller publish", %{conn: conn, handle: handle, repo: repo} do | |
| 93 | + conn = | |
| 94 | + conn | |
| 95 | + |> put_req_header("content-type", "application/octet-stream") | |
| 96 | + |> put("/pkg/fdroid/#{handle}/#{repo.name}/9.9.9/app.apk", @firmware) | |
| 97 | + | |
| 98 | + assert conn.status in [401, 403] | |
| 99 | + end | |
| 100 | + | |
| 101 | + test "writes are refused for the opted-in format too", %{repo: repo} do | |
| 102 | + assert Auth.authorize(:anonymous, repo, :write, "fdroid") == false | |
| 103 | + end | |
| 104 | + | |
| 105 | + test "an unticked format is still refused at the authorize level", %{repo: repo} do | |
| 106 | + assert Auth.authorize(:anonymous, repo, :read, "raw") == false | |
| 107 | + assert Auth.authorize(:anonymous, repo, :read, "fdroid") == true | |
| 108 | + end | |
| 109 | + end | |
| 110 | + | |
| 111 | + describe "the catalog the dashboard renders" do | |
| 112 | + test "lists the format only under the one opted into", %{user: user, repo: repo} do | |
| 113 | + repo = publish_formats(repo, ["raw"]) | |
| 114 | + assert Auth.public_format?(repo, "raw") | |
| 115 | + | |
| 116 | + catalog = Packages.namespace_catalog(user, :anonymous) | |
| 117 | + assert Map.has_key?(catalog, "raw") | |
| 118 | + | |
| 119 | + # And with nothing ticked, the anonymous catalog is empty again. | |
| 120 | + publish_formats(repo, []) | |
| 121 | + assert Packages.namespace_catalog(user, :anonymous) == %{} | |
| 122 | + end | |
| 123 | + end | |
| 124 | + | |
| 125 | + describe "the setting itself" do | |
| 126 | + test "rejects a format the forge does not serve", %{repo: repo} do | |
| 127 | + assert {:error, changeset} = | |
| 128 | + Repositories.update_repository(repo, %{ | |
| 129 | + "public_package_formats" => ["raw", "npm"] | |
| 130 | + }) | |
| 131 | + | |
| 132 | + errors = Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end) | |
| 133 | + assert "has an invalid entry" in errors.public_package_formats | |
| 134 | + end | |
| 135 | + | |
| 136 | + test "clearing every box stores an empty list, not a blank entry", %{repo: repo} do | |
| 137 | + repo = publish_formats(repo, ["raw", ""]) | |
| 138 | + assert repo.public_package_formats == ["raw"] | |
| 139 | + | |
| 140 | + repo = publish_formats(repo, [""]) | |
| 141 | + assert repo.public_package_formats == [] | |
| 142 | + end | |
| 143 | + end | |
| 144 | +end |
Parents: 3b5eb4d