defmodule GitGud.Packages.Usage do
@moduledoc """
The copy-pasteable instructions for each registry format.
Two facets, because there are two audiences and they need different
things:
* **Package manager** — a person at a terminal, or a `mix.exs` /
`Cargo.toml` / `requirements.txt` on someone's laptop. They need
the line that points their tool at this repository, and the line
that publishes to it.
* **CI** — a workflow in this repository. It needs neither of those
lines verbatim, because it has `${{ github.token }}` and no
business creating a registry token at all. What it needs is a
step it can paste.
Kept out of the LiveView because it is a pile of string formatting
with real rules in it (Hex checks the repository name it was added
under; apt needs the key pinned before it will look at the repo at
all), and that deserves tests of its own.
Everything here is a pure function of the registry context, so a
snippet can be asserted without rendering a page.
"""
alias GitGud.Packages.Formats.Hex, as: HexFormat
alias GitGud.Repositories.Repository
alias GitGud.Repositories.Storage
@typedoc """
Everything a snippet needs to name this repository's registry.
Build it with `context/2`.
"""
@type context :: %{
handle: String.t(),
repo_name: String.t(),
root: String.t(),
hex_repo: String.t(),
oci_host: String.t() | nil
}
@typedoc """
One format's instructions. Any field may be `nil` where the format
has nothing to say — OCI has no "configure" step, because the pull
reference carries the host itself.
"""
@type usage :: %{
consume: [step()],
publish: [step()],
ci: [step()]
}
@typedoc "A labelled block of shell, YAML or config."
@type step :: %{label: String.t(), language: String.t(), code: String.t()}
@doc "Build the context the snippets are rendered from."
@spec context(Repository.t(), String.t() | nil) :: context()
def context(%Repository{} = repo, oci_host \\ nil) do
repo = GitGud.Repo.preload(repo, [:owner, :organization])
%{
handle: Storage.repo_handle(repo),
repo_name: repo.name,
root: registry_root(),
hex_repo: HexFormat.repo_name(repo),
oci_host: oci_host
}
end
defp registry_root do
(Application.get_env(:git_gud, :package_registry_url) || GitGudWeb.Endpoint.url())
|> String.trim_trailing("/")
end
@doc "The registry root URL for one format under this repository."
@spec base_url(context(), String.t()) :: String.t()
def base_url(ctx, format),
do: "#{ctx.root}/pkg/#{format}/#{ctx.handle}/#{ctx.repo_name}"
@doc """
The namespace's public signing key for a format.
This, not the per-repository path, is what instructions should point
at: the key belongs to the namespace, so one keyring entry covers
every repository the organization publishes.
"""
@spec key_url(context(), String.t()) :: String.t()
def key_url(ctx, format), do: "#{ctx.root}/pkg/_keys/#{format}/#{ctx.handle}"
@doc """
The namespace-wide registry root — every repository in the namespace
the caller may read, as one repository.
For apt this is usually the line you actually want: one `deb` entry
and one keyring file for what an administrator thinks of as one
publisher, rather than one of each per repository.
"""
@spec namespace_url(context(), String.t()) :: String.t()
def namespace_url(ctx, format),
do: "#{ctx.root}/pkg/#{format}/#{ctx.handle}/#{GitGud.Packages.Format.namespace_marker()}"
@doc """
Instructions for `format`, or `nil` if there is nothing to say.
"""
@spec for_format(String.t(), context()) :: usage() | nil
def for_format(format, ctx)
# ── PyPI ────────────────────────────────────────────────────────────
def for_format("pypi", ctx) do
url = base_url(ctx, "pypi")
%{
consume: [
step(
"Install from this index",
"shell",
"""
pip install --index-url #{url}/simple/ <package>
"""
),
step(
"…or pin it for a project",
"shell",
"""
# requirements.txt
--index-url #{url}/simple/
<package>==<version>
"""
),
step(
"A private repository needs a token",
"shell",
"""
pip install --index-url https://__token__:$GITGUD_TOKEN@#{strip_scheme(url)}/simple/ <package>
"""
)
],
publish: [
step(
"Upload with twine",
"shell",
"""
twine upload \\
--repository-url #{url}/ \\
--username __token__ \\
--password $GITGUD_TOKEN \\
dist/*
"""
)
],
ci: [
step(
"Publish from a workflow",
"yaml",
"""
- name: Publish to the forge
run: |
pip install build twine
python -m build
twine upload --repository-url #{url}/ dist/*
env:
# The per-job token. It may publish to this repository's
# own registry and nothing else, so there is no secret to
# create first.
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ github.token }}
"""
)
]
}
end
# ── Hex ─────────────────────────────────────────────────────────────
def for_format("hex", ctx) do
url = base_url(ctx, "hex")
%{
consume: [
step(
"Add the repository — the key covers all of #{ctx.handle}",
"shell",
"""
mix hex.repo add #{ctx.hex_repo} #{url} \\
--public-key=<(curl -fsSL #{key_url(ctx, "hex")})
"""
),
step(
"Depend on a package",
"elixir",
"""
# mix.exs — the `repo:` must match the name above; Hex checks it
{:<package>, "~> <version>", repo: "#{ctx.hex_repo}"}
"""
)
],
publish: [
step(
"Build and publish a tarball",
"shell",
"""
mix hex.build # produces <name>-<version>.tar
curl -fsSL --fail \\
-H "Authorization: $GITGUD_TOKEN" \\
-H "Content-Type: application/octet-stream" \\
--data-binary @<name>-<version>.tar \\
#{url}/publish
"""
)
],
ci: [
step(
"Publish from a workflow",
"yaml",
"""
- name: Publish to the forge
run: |
mix hex.build
curl -fsSL --fail \\
-H "Authorization: ${{ github.token }}" \\
-H "Content-Type: application/octet-stream" \\
--data-binary @$(ls *.tar) \\
#{url}/publish
"""
),
step(
"…or consume from one",
"yaml",
"""
- name: Point mix at the forge
run: |
mix hex.repo add #{ctx.hex_repo} #{url} \\
--public-key=<(curl -fsSL #{key_url(ctx, "hex")})
mix deps.get
"""
)
]
}
end
# ── Cargo ───────────────────────────────────────────────────────────
def for_format("cargo", ctx) do
url = base_url(ctx, "cargo")
registry = ctx.repo_name
%{
consume: [
step(
"Register the index",
"toml",
"""
# .cargo/config.toml
[registries.#{registry}]
index = "sparse+#{url}/"
"""
),
step(
"Depend on a crate",
"toml",
"""
# Cargo.toml
[dependencies]
<crate> = { version = "<version>", registry = "#{registry}" }
"""
)
],
publish: [
step(
"Log in, then publish",
"shell",
"""
cargo login --registry #{registry} $GITGUD_TOKEN
cargo publish --registry #{registry}
"""
),
step(
"Yank a bad release",
"shell",
"""
cargo yank --registry #{registry} <crate>@<version>
"""
)
],
ci: [
step(
"Publish from a workflow",
"yaml",
"""
- name: Publish to the forge
run: |
mkdir -p .cargo
cat >> .cargo/config.toml <<'TOML'
[registries.#{registry}]
index = "sparse+#{url}/"
TOML
cargo publish --registry #{registry}
env:
# Cargo reads the token for registry `<name>` from
# CARGO_REGISTRIES_<NAME>_TOKEN. The per-job token may
# publish to this repository only.
CARGO_REGISTRIES_#{env_name(registry)}_TOKEN: ${{ github.token }}
"""
)
]
}
end
# ── apt ─────────────────────────────────────────────────────────────
def for_format("deb", ctx) do
url = base_url(ctx, "deb")
name = ctx.repo_name
%{
consume: [
step(
"Trust #{ctx.handle}'s key — once per machine, for everything it publishes",
"shell",
"""
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL #{key_url(ctx, "deb")} \\
| sudo tee /etc/apt/keyrings/#{ctx.handle}.asc > /dev/null
"""
),
step(
"Add everything #{ctx.handle} publishes, as one repository",
"shell",
"""
echo "deb [signed-by=/etc/apt/keyrings/#{ctx.handle}.asc] #{namespace_url(ctx, "deb")} stable main" \\
| sudo tee /etc/apt/sources.list.d/#{ctx.handle}.list
sudo apt-get update
"""
),
step(
"…or just this repository",
"shell",
"""
echo "deb [signed-by=/etc/apt/keyrings/#{ctx.handle}.asc] #{url} stable main" \\
| sudo tee /etc/apt/sources.list.d/#{name}.list
sudo apt-get update
"""
),
step(
"A private repository needs a token",
"shell",
"""
# /etc/apt/auth.conf.d/#{name}.conf (chmod 600)
machine #{host_of(url)}
login __token__
password <ggrt_…>
"""
),
step(
"Browse it in a browser — same tree apt reads, same permissions",
"shell",
"""
#{namespace_url(ctx, "deb")}/ # everything #{ctx.handle} publishes
#{url}/ # just this repository
"""
)
],
publish: [
step(
"Upload a .deb",
"shell",
"""
curl -fsSL --fail \\
-H "Authorization: $GITGUD_TOKEN" \\
-H "Content-Type: application/octet-stream" \\
--data-binary @<package>_<version>_<arch>.deb \\
"#{url}/upload?dist=stable&component=main"
"""
)
],
ci: [
step(
"Publish from a workflow",
"yaml",
"""
- name: Publish to the forge
run: |
for deb in target/debian/*.deb; do
curl -fsSL --fail \\
-H "Authorization: ${{ github.token }}" \\
-H "Content-Type: application/octet-stream" \\
--data-binary @"$deb" \\
"#{url}/upload?dist=stable&component=main"
done
"""
)
]
}
end
# ── dnf ─────────────────────────────────────────────────────────────
def for_format("rpm", ctx) do
url = base_url(ctx, "rpm")
name = ctx.repo_name
%{
consume: [
step(
"Add everything #{ctx.handle} publishes, as one repository",
"shell",
"""
# /etc/yum.repos.d/#{ctx.handle}.repo
[#{ctx.handle}]
name=#{ctx.handle}
baseurl=#{namespace_url(ctx, "rpm")}
enabled=1
# Verify the repository index against #{ctx.handle}'s key…
repo_gpgcheck=1
# …and each package against whoever signed it at build time.
# Set to 0 if you publish unsigned packages from CI.
gpgcheck=1
gpgkey=#{key_url(ctx, "rpm")}
"""
),
step(
"…or just this repository",
"shell",
"""
# /etc/yum.repos.d/#{name}.repo
[#{name}]
name=#{name}
baseurl=#{url}
enabled=1
repo_gpgcheck=1
gpgcheck=1
gpgkey=#{key_url(ctx, "rpm")}
"""
),
step(
"A private repository needs a token",
"shell",
"""
# in the same .repo file — dnf sends these as HTTP Basic
username=__token__
password=<ggrt_…>
"""
),
step(
"Install",
"shell",
"""
sudo dnf install <package>
# or, on openSUSE:
sudo zypper addrepo #{namespace_url(ctx, "rpm")} #{ctx.handle}
"""
),
step(
"Browse it in a browser — same tree dnf reads, same permissions",
"shell",
"""
#{namespace_url(ctx, "rpm")}/ # everything #{ctx.handle} publishes
#{url}/ # just this repository
"""
)
],
publish: [
step(
"Upload an .rpm",
"shell",
"""
curl -fsSL --fail \\
-H "Authorization: $GITGUD_TOKEN" \\
-H "Content-Type: application/octet-stream" \\
--data-binary @<name>-<version>-<release>.<arch>.rpm \\
"#{url}/upload"
"""
)
],
ci: [
step(
"Publish from a workflow",
"yaml",
"""
- name: Publish to the forge
run: |
for rpm in target/generate-rpm/*.rpm; do
curl -fsSL --fail \\
-H "Authorization: ${{ github.token }}" \\
-H "Content-Type: application/octet-stream" \\
--data-binary @"$rpm" \\
"#{url}/upload"
done
"""
)
]
}
end
# ── F-Droid ─────────────────────────────────────────────────────────
def for_format("fdroid", ctx) do
url = base_url(ctx, "fdroid")
%{
consume: [
step(
"Read the fingerprint, for scripting or to check the one above",
"shell",
"""
curl -fsSL #{key_url(ctx, "fdroid")}
"""
),
step(
"…or add just this repository, with the same fingerprint",
"shell",
"""
#{url}/repo?fingerprint=$(curl -fsSL #{key_url(ctx, "fdroid")})
"""
),
step(
"A private repository needs a token",
"shell",
"""
# F-Droid: Settings -> Repositories -> this repo -> Username / Password
username: __token__
password: <ggrt_…>
"""
)
],
publish: [
step(
"Upload an APK",
"shell",
"""
curl -fsSL --fail \\
-H "Authorization: $GITGUD_TOKEN" \\
-H "Content-Type: application/vnd.android.package-archive" \\
--data-binary @app-release.apk \\
"#{url}/upload"
"""
)
],
ci: [
step(
"Publish from a workflow",
"yaml",
"""
- name: Publish to the F-Droid repository
run: |
curl -fsSL --fail \\
-H "Authorization: ${{ github.token }}" \\
-H "Content-Type: application/vnd.android.package-archive" \\
--data-binary @app-release.apk \\
"#{url}/upload"
"""
)
]
}
end
# ── OCI ─────────────────────────────────────────────────────────────
# Container images are zot's, not ours; these instructions describe
# the registry the images actually live in.
def for_format("oci", %{oci_host: host} = ctx) when is_binary(host) do
image = "#{host}/#{ctx.handle}/#{ctx.repo_name}"
%{
consume: [step("Pull an image", "shell", "podman pull #{image}:<tag>\n")],
publish: [
step(
"Log in, then push",
"shell",
"""
podman login #{host} --username <you> --password $GITGUD_TOKEN
podman push #{image}:<tag>
"""
)
],
ci: [
step(
"Publish from a workflow",
"yaml",
"""
- uses: docker/login-action@v3
with:
registry: #{host}
# Ignored by the token endpoint, but login-action insists
# on something non-empty.
username: gitgud-actions
password: ${{ github.token }}
- uses: docker/build-push-action@v6
with:
push: true
tags: #{image}:${{ github.sha }}
"""
)
]
}
end
# ── raw ─────────────────────────────────────────────────────────────
def for_format("raw", ctx) do
url = base_url(ctx, "raw")
%{
consume: [
step(
"Everything published, with a URL and a checksum per file",
"shell",
"""
curl -fsSL #{url}/manifest.json
"""
),
step(
"Fetch the newest bytes without resolving a version first",
"shell",
"""
curl -fsSLO #{url}/latest/<filename>
"""
),
step(
"…or one version, which never changes once published",
"shell",
"""
curl -fsSLO #{url}/<version>/<filename>
"""
)
],
publish: [
step(
"Publish a file under a version",
"shell",
"""
curl -fsSL --fail \\
-H "Authorization: $GITGUD_TOKEN" \\
--upload-file ./build/firmware.bin \\
"#{url}/1.2.3/firmware.bin"
"""
)
],
ci: [
step(
"Publish from a workflow",
"yaml",
"""
- name: Publish the build
run: |
version="1.2.3+ci$(date +%Y%m%d).g${GITHUB_SHA::7}"
curl -fsSL --fail \\
-H "Authorization: ${{ github.token }}" \\
--upload-file ./build/firmware.bin \\
"#{url}/$version/firmware.bin"
"""
)
]
}
end
def for_format(_format, _ctx), do: nil
@doc """
The one-liner that installs a specific version.
Lives here rather than in the page that first needed it, because the
Android client shows the same string and two copies of
`pip install --index-url …` would drift the first time a flag
changed.
`nil` for a format with no meaningful one-liner — OCI needs a registry
host this deployment may not have configured, and a format nobody has
written a line for is better represented as an absence than as a
guess the reader would paste.
"""
@spec install_line(String.t(), context(), %{name: String.t()}, String.t()) :: String.t() | nil
def install_line(format, ctx, package, version)
def install_line("oci", %{oci_host: host} = ctx, pkg, version) when is_binary(host) do
base =
if pkg.name == ctx.repo_name,
do: "#{ctx.handle}/#{ctx.repo_name}",
else: "#{ctx.handle}/#{ctx.repo_name}/#{pkg.name}"
"podman pull #{host}/#{base}:#{version}"
end
def install_line("pypi", ctx, pkg, version),
do: "pip install --index-url #{base_url(ctx, "pypi")}/simple/ #{pkg.name}==#{version}"
def install_line("hex", ctx, pkg, version),
do: ~s[{:#{pkg.name}, "~> #{version}", repo: "#{ctx.hex_repo}"}]
def install_line("cargo", ctx, pkg, version),
do: "cargo add #{pkg.name}@#{version} --registry #{ctx.repo_name}"
# apt and dnf both want the repository configured first; these are the
# line you run after that, which is what someone reading a version
# list is looking for.
def install_line("deb", _ctx, pkg, version),
do: "sudo apt-get install #{pkg.name}=#{version}"
def install_line("rpm", _ctx, pkg, version),
do: "sudo dnf install #{pkg.name}-#{version}"
# No package manager to name, so the useful line is the fetch — and
# the version's own URL rather than `latest/`, since a reader looking
# at a version list means that one.
def install_line("raw", ctx, _pkg, version),
do: "curl -fsSLO #{base_url(ctx, "raw")}/#{version}/<filename>"
def install_line(_format, _ctx, _pkg, _version), do: nil
@doc """
The one-line summary shown next to a format heading — what to say
when the reader hasn't opened the instructions yet.
"""
@spec summary(String.t()) :: String.t() | nil
def summary("pypi"), do: "PEP 503 / PEP 691 simple index — pip, uv, poetry, twine."
def summary("hex"), do: "Hex registry v2, signed. Works with mix out of the box."
def summary("cargo"), do: "Sparse HTTP index — cargo publish, add, yank."
def summary("deb"), do: "A signed Debian repository — apt-get update and install."
def summary("rpm"),
do: "A signed rpm-md repository — dnf and zypper read it as-is."
def summary("fdroid"),
do: "An F-Droid app repository — add it once, install and update from the phone."
def summary("raw"),
do: "Static files with a JSON manifest — for clients with no package manager."
def summary("oci"), do: "Container images, served by the bundled OCI registry."
def summary(_), do: nil
# ── helpers ─────────────────────────────────────────────────────────
defp step(label, language, code),
do: %{label: label, language: language, code: String.trim_trailing(code)}
# Cargo maps `registries.my-crate` to CARGO_REGISTRIES_MY_CRATE_TOKEN:
# uppercased, with every non-alphanumeric run folded to `_`.
defp env_name(registry) do
registry
|> String.upcase()
|> String.replace(~r/[^A-Z0-9]+/, "_")
end
defp strip_scheme(url), do: String.replace(url, ~r{^https?://}, "")
defp host_of(url), do: url |> URI.parse() |> Map.get(:host) || "localhost"
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
23.1 KiB · text
History
b868907