16.4 KiB · text History 413e987
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

  # ── 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

  def for_format(_format, _ctx), 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("fdroid"),
    do: "An F-Droid app repository — add it once, install and update from the phone."

  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