Let a repository publish named package formats regardless of visibility

439db79 · Gabriel Morell · 2026-09-19 00:28

22 files +309 -8
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