8.0 KiB · text History b868907
defmodule GitGudWeb.GitGudDebPackageTest do
  @moduledoc """
  The forge's own `.deb` — `packaging/deb/`, built by CI in
  `.forgejo/workflows/build.yml` and published to this forge's apt
  repository.

  The dpkg-deb assembly itself is CI's business (and needs Debian
  tooling). What's checked here is the part that can silently be wrong
  and only show up as a failed `apt install`: the control stanza our
  template renders, read back by the same parser and index renderer
  that serve it.
  """
  use GitGudWeb.ConnCase, async: false

  alias GitGud.Packages
  alias GitGud.Packages.Formats.Deb.Control
  alias GitGud.Repositories.Storage

  import GitGud.ForgeFixtures

  @packaging Path.expand("../../packaging/deb", __DIR__)

  setup do
    {user, repo} = repository_fixture(%{visibility: "public"})
    {:ok, _t, token} = Packages.create_registry_token(repo, "ci", ["pull", "push"])

    {:ok, repo: repo, token: token, root: "/pkg/deb/#{Storage.owner_handle(user)}/#{repo.name}"}
  end

  test "the rendered control stanza carries what apt requires" do
    assert {:ok, control} = Control.parse_stanza(rendered_control())

    assert control["Package"] == "gitgud"
    assert control["Version"] == "0.1.0~ci20260912000000.gabcdef123456"
    assert control["Architecture"] == "amd64"
    assert control["Maintainer"] =~ "@"
    assert control["Section"] == "devel"
    assert control["Priority"] == "optional"
    assert String.to_integer(control["Installed-Size"]) > 0

    # The runtime the release can't bundle: git and curl are load-bearing
    # (the Cli backend, and the push hooks), openssh-client ships
    # ssh-keygen for the embedded daemon's host keys.
    for dep <- ~w(git curl openssh-client ca-certificates adduser) do
      assert control["Depends"] =~ dep
    end

    # `locales` is deliberately absent: the unit runs under C.UTF-8,
    # which glibc carries, so nothing needs locale-gen.
    refute control["Depends"] =~ "locales"

    # A folded description: the synopsis on the Description line, the
    # rest indented by one space, with `.` for blank lines. Getting this
    # wrong turns the following fields into description text.
    assert [synopsis | body] = String.split(control["Description"], "\n")
    assert synopsis =~ "git forge"
    assert Enum.any?(body, &(&1 =~ "systemd"))
    assert Enum.any?(body, &(&1 =~ "abcdef123456")), "the commit should be recorded"
  end

  test "a .deb with that control publishes and indexes", ctx do
    {:ok, control} = Control.parse_stanza(rendered_control())
    package = deb(control)

    conn =
      build_conn()
      |> put_req_header("authorization", "Bearer #{ctx.token}")
      |> put_req_header("content-type", "application/vnd.debian.binary-package")
      |> post("#{ctx.root}/upload?dist=trixie&component=main", package)

    assert json_response(conn, 200) == %{
             "package" => "gitgud",
             "version" => control["Version"],
             "architecture" => "amd64",
             "dist" => "trixie",
             "component" => "main"
           }

    packages =
      build_conn()
      |> get("#{ctx.root}/dists/trixie/main/binary-amd64/Packages")
      |> response(200)

    assert packages =~ "Package: gitgud"
    assert packages =~ "Version: #{control["Version"]}"
    assert packages =~ "Filename: pool/main/"
    # apt 2.7.7+ refuses an index without it.
    assert packages =~ "Description-md5: "
  end

  test "the same release publishes to trixie and forky as separate versions", ctx do
    {:ok, base} = Control.parse_stanza(rendered_control())

    # What `SUITE=<suite> build-deb.sh` produces: one release, one
    # version (and so one pool filename) per suite.
    for suite <- ~w(trixie forky) do
      control = Map.put(base, "Version", base["Version"] <> "+" <> suite)

      conn =
        build_conn()
        |> put_req_header("authorization", "Bearer #{ctx.token}")
        |> put_req_header("content-type", "application/vnd.debian.binary-package")
        |> post("#{ctx.root}/upload?dist=#{suite}&component=main", deb(control))

      assert json_response(conn, 200)["version"] == control["Version"]
    end

    for suite <- ~w(trixie forky) do
      index =
        build_conn()
        |> get("#{ctx.root}/dists/#{suite}/main/binary-amd64/Packages")
        |> response(200)

      # Each suite's index names its own build and only its own.
      assert index =~ "Version: #{base["Version"]}+#{suite}"
      refute index =~ "+#{other_suite(suite)}"
      assert index =~ "Filename: pool/main/"
    end
  end

  defp other_suite("trixie"), do: "forky"
  defp other_suite("forky"), do: "trixie"

  test "the maintainer scripts are POSIX sh and set -e" do
    for name <- ~w(postinst prerm postrm) do
      script = File.read!(Path.join(@packaging, name))

      assert String.starts_with?(script, "#!/bin/sh\n")
      assert script =~ "set -e"
      # `dpkg` runs these on a machine that may have no systemd (a
      # container, a chroot); every call has to be guarded.
      if script =~ "systemctl" do
        assert script =~ "[ -d /run/systemd/system ]"
      end
    end
  end

  test "the env file and unit agree on the paths the postinst creates" do
    env = File.read!(Path.join(@packaging, "gitgud.env"))
    unit = File.read!(Path.join(@packaging, "gitgud.service"))
    postinst = File.read!(Path.join(@packaging, "postinst"))

    assert env =~ "GIT_REPOS_PATH=/var/lib/gitgud/repos"
    assert env =~ "STORAGE_DISK_ROOT=/var/lib/gitgud/storage"
    assert postinst =~ "/var/lib/gitgud/repos"
    assert postinst =~ "/var/lib/gitgud/storage"

    # RELEASE_TMP has to be writable by the service user, and
    # /opt/gitgud is not.
    assert unit =~ "Environment=RELEASE_TMP=/var/lib/gitgud/tmp"

    # systemd starts a service with no locale, and the BEAM then comes
    # up with native name encoding latin1 — Elixir warns on every boot
    # and non-ASCII filenames mangle. Verified by booting the release in
    # a bare trixie container: without this, `:file.native_name_encoding`
    # is `latin1`; with it, `utf8`.
    assert unit =~ "Environment=LANG=C.UTF-8"
    assert unit =~ "Environment=LC_ALL=C.UTF-8"
    assert postinst =~ "/var/lib/gitgud/tmp"
    assert unit =~ "EnvironmentFile=/etc/gitgud/gitgud.env"
    assert unit =~ "ExecStart=/opt/gitgud/bin/server"
  end

  # ── helpers ──────────────────────────────────────────────────────────

  # What `build-deb.sh`'s `sed` produces, with the same substitutions.
  defp rendered_control do
    @packaging
    |> Path.join("control.in")
    |> File.read!()
    |> String.replace("@VERSION@", "0.1.0~ci20260912000000.gabcdef123456")
    |> String.replace("@ARCH@", "amd64")
    |> String.replace("@COMMIT@", "abcdef123456789")
    |> String.replace("@MAINTAINER@", "GitGud CI <noreply@example.com>")
    |> String.replace("@HOMEPAGE@", "https://example.com/acme/gitgud")
    |> String.replace("@INSTALLED_SIZE@", "180000")
  end

  defp deb(control) do
    stanza =
      control
      |> Enum.sort()
      |> Enum.map_join(fn {k, v} -> "#{k}: #{v}\n" end)

    ar([
      {"debian-binary", "2.0\n"},
      {"control.tar.gz", :zlib.gzip(tar([{~c"./control", stanza}]))},
      {"data.tar.gz", :zlib.gzip(tar([{~c"./opt/gitgud/bin/server", "#!/bin/sh\n"}]))}
    ])
  end

  defp tar(members) do
    path = Path.join(System.tmp_dir!(), "t-#{System.unique_integer([:positive])}.tar")
    :ok = :erl_tar.create(String.to_charlist(path), members, [])
    File.read!(path)
  end

  defp ar(members) do
    body =
      Enum.map_join(members, fn {name, data} ->
        header =
          String.pad_trailing(name, 16) <>
            String.pad_trailing("0", 12) <>
            String.pad_trailing("0", 6) <>
            String.pad_trailing("0", 6) <>
            String.pad_trailing("100644", 8) <>
            String.pad_trailing(Integer.to_string(byte_size(data)), 10) <>
            <<0x60, 0x0A>>

        padding = if rem(byte_size(data), 2) == 1, do: "\n", else: ""
        header <> data <> padding
      end)

    "!<arch>\n" <> body
  end
end