diff --git a/models/packages/descriptor.go b/models/packages/descriptor.go
index b8ef698d38..803b73c968 100644
--- a/models/packages/descriptor.go
+++ b/models/packages/descriptor.go
@@ -13,6 +13,7 @@ import (
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/json"
 	"code.gitea.io/gitea/modules/packages/alpine"
+	"code.gitea.io/gitea/modules/packages/arch"
 	"code.gitea.io/gitea/modules/packages/cargo"
 	"code.gitea.io/gitea/modules/packages/chef"
 	"code.gitea.io/gitea/modules/packages/composer"
@@ -150,6 +151,8 @@ func GetPackageDescriptor(ctx context.Context, pv *PackageVersion) (*PackageDesc
 	switch p.Type {
 	case TypeAlpine:
 		metadata = &alpine.VersionMetadata{}
+	case TypeArch:
+		metadata = &arch.VersionMetadata{}
 	case TypeCargo:
 		metadata = &cargo.Metadata{}
 	case TypeChef:
diff --git a/models/packages/package.go b/models/packages/package.go
index 84e2fa7ee7..364cc2e7cc 100644
--- a/models/packages/package.go
+++ b/models/packages/package.go
@@ -33,6 +33,7 @@ type Type string
 // List of supported packages
 const (
 	TypeAlpine    Type = "alpine"
+	TypeArch      Type = "arch"
 	TypeCargo     Type = "cargo"
 	TypeChef      Type = "chef"
 	TypeComposer  Type = "composer"
@@ -57,6 +58,7 @@ const (
 
 var TypeList = []Type{
 	TypeAlpine,
+	TypeArch,
 	TypeCargo,
 	TypeChef,
 	TypeComposer,
@@ -84,6 +86,8 @@ func (pt Type) Name() string {
 	switch pt {
 	case TypeAlpine:
 		return "Alpine"
+	case TypeArch:
+		return "Arch"
 	case TypeCargo:
 		return "Cargo"
 	case TypeChef:
@@ -133,6 +137,8 @@ func (pt Type) SVGName() string {
 	switch pt {
 	case TypeAlpine:
 		return "gitea-alpine"
+	case TypeArch:
+		return "gitea-arch"
 	case TypeCargo:
 		return "gitea-cargo"
 	case TypeChef:
diff --git a/modules/packages/arch/metadata.go b/modules/packages/arch/metadata.go
new file mode 100644
index 0000000000..fc66d288cc
--- /dev/null
+++ b/modules/packages/arch/metadata.go
@@ -0,0 +1,316 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package arch
+
+import (
+	"bufio"
+	"bytes"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"io"
+	"regexp"
+	"strconv"
+	"strings"
+
+	"code.gitea.io/gitea/modules/packages"
+	"code.gitea.io/gitea/modules/util"
+	"code.gitea.io/gitea/modules/validation"
+
+	"github.com/mholt/archiver/v3"
+)
+
+// Arch Linux Packages
+// https://man.archlinux.org/man/PKGBUILD.5
+
+const (
+	PropertyDescription  = "arch.description"
+	PropertyArch         = "arch.architecture"
+	PropertyDistribution = "arch.distribution"
+
+	SettingKeyPrivate = "arch.key.private"
+	SettingKeyPublic  = "arch.key.public"
+
+	RepositoryPackage = "_arch"
+	RepositoryVersion = "_repository"
+)
+
+var (
+	reName   = regexp.MustCompile(`^[a-zA-Z0-9@._+-]+$`)
+	reVer    = regexp.MustCompile(`^[a-zA-Z0-9:_.+]+-+[0-9]+$`)
+	reOptDep = regexp.MustCompile(`^[a-zA-Z0-9@._+-]+$|^[a-zA-Z0-9@._+-]+(:.*)`)
+	rePkgVer = regexp.MustCompile(`^[a-zA-Z0-9@._+-]+$|^[a-zA-Z0-9@._+-]+(>.*)|^[a-zA-Z0-9@._+-]+(<.*)|^[a-zA-Z0-9@._+-]+(=.*)`)
+)
+
+type Package struct {
+	Name            string `json:"name"`
+	Version         string `json:"version"` // Includes version, release and epoch
+	VersionMetadata VersionMetadata
+	FileMetadata    FileMetadata
+}
+
+// Arch package metadata related to specific version.
+// Version metadata the same across different architectures and distributions.
+type VersionMetadata struct {
+	Base         string   `json:"base"`
+	Description  string   `json:"description"`
+	ProjectURL   string   `json:"project_url"`
+	Groups       []string `json:"groups,omitempty"`
+	Provides     []string `json:"provides,omitempty"`
+	License      []string `json:"license,omitempty"`
+	Depends      []string `json:"depends,omitempty"`
+	OptDepends   []string `json:"opt_depends,omitempty"`
+	MakeDepends  []string `json:"make_depends,omitempty"`
+	CheckDepends []string `json:"check_depends,omitempty"`
+	Conflicts    []string `json:"conflicts,omitempty"`
+	Replaces     []string `json:"replaces,omitempty"`
+	Backup       []string `json:"backup,omitempty"`
+	Xdata        []string `json:"xdata,omitempty"`
+}
+
+// FileMetadata Metadata related to specific package file.
+// This metadata might vary for different architecture and distribution.
+type FileMetadata struct {
+	CompressedSize int64  `json:"compressed_size"`
+	InstalledSize  int64  `json:"installed_size"`
+	MD5            string `json:"md5"`
+	SHA256         string `json:"sha256"`
+	BuildDate      int64  `json:"build_date"`
+	Packager       string `json:"packager"`
+	Arch           string `json:"arch"`
+	PgpSigned      string `json:"pgp"`
+}
+
+// ParsePackage Function that receives arch package archive data and returns it's metadata.
+func ParsePackage(r *packages.HashedBuffer) (*Package, error) {
+	md5, _, sha256, _ := r.Sums()
+	_, err := r.Seek(0, io.SeekStart)
+	if err != nil {
+		return nil, err
+	}
+	zstd := archiver.NewTarZstd()
+	err = zstd.Open(r, 0)
+	if err != nil {
+		return nil, err
+	}
+	defer zstd.Close()
+
+	var pkg *Package
+	var mtree bool
+
+	for {
+		f, err := zstd.Read()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return nil, err
+		}
+		defer f.Close()
+
+		switch f.Name() {
+		case ".PKGINFO":
+			pkg, err = ParsePackageInfo(f)
+			if err != nil {
+				return nil, err
+			}
+		case ".MTREE":
+			mtree = true
+		}
+	}
+
+	if pkg == nil {
+		return nil, util.NewInvalidArgumentErrorf(".PKGINFO file not found")
+	}
+
+	if !mtree {
+		return nil, util.NewInvalidArgumentErrorf(".MTREE file not found")
+	}
+
+	pkg.FileMetadata.CompressedSize = r.Size()
+	pkg.FileMetadata.MD5 = hex.EncodeToString(md5)
+	pkg.FileMetadata.SHA256 = hex.EncodeToString(sha256)
+
+	return pkg, nil
+}
+
+// ParsePackageInfo Function that accepts reader for .PKGINFO file from package archive,
+// validates all field according to PKGBUILD spec and returns package.
+func ParsePackageInfo(r io.Reader) (*Package, error) {
+	p := &Package{}
+
+	scanner := bufio.NewScanner(r)
+	for scanner.Scan() {
+		line := scanner.Text()
+
+		if strings.HasPrefix(line, "#") {
+			continue
+		}
+
+		key, value, find := strings.Cut(line, "=")
+		if !find {
+			continue
+		}
+		key = strings.TrimSpace(key)
+		value = strings.TrimSpace(value)
+		switch key {
+		case "pkgname":
+			p.Name = value
+		case "pkgbase":
+			p.VersionMetadata.Base = value
+		case "pkgver":
+			p.Version = value
+		case "pkgdesc":
+			p.VersionMetadata.Description = value
+		case "url":
+			p.VersionMetadata.ProjectURL = value
+		case "packager":
+			p.FileMetadata.Packager = value
+		case "arch":
+			p.FileMetadata.Arch = value
+		case "provides":
+			p.VersionMetadata.Provides = append(p.VersionMetadata.Provides, value)
+		case "license":
+			p.VersionMetadata.License = append(p.VersionMetadata.License, value)
+		case "depend":
+			p.VersionMetadata.Depends = append(p.VersionMetadata.Depends, value)
+		case "optdepend":
+			p.VersionMetadata.OptDepends = append(p.VersionMetadata.OptDepends, value)
+		case "makedepend":
+			p.VersionMetadata.MakeDepends = append(p.VersionMetadata.MakeDepends, value)
+		case "checkdepend":
+			p.VersionMetadata.CheckDepends = append(p.VersionMetadata.CheckDepends, value)
+		case "backup":
+			p.VersionMetadata.Backup = append(p.VersionMetadata.Backup, value)
+		case "group":
+			p.VersionMetadata.Groups = append(p.VersionMetadata.Groups, value)
+		case "conflict":
+			p.VersionMetadata.Conflicts = append(p.VersionMetadata.Conflicts, value)
+		case "replaces":
+			p.VersionMetadata.Replaces = append(p.VersionMetadata.Replaces, value)
+		case "xdata":
+			p.VersionMetadata.Xdata = append(p.VersionMetadata.Xdata, value)
+		case "builddate":
+			bd, err := strconv.ParseInt(value, 10, 64)
+			if err != nil {
+				return nil, err
+			}
+			p.FileMetadata.BuildDate = bd
+		case "size":
+			is, err := strconv.ParseInt(value, 10, 64)
+			if err != nil {
+				return nil, err
+			}
+			p.FileMetadata.InstalledSize = is
+		default:
+			return nil, util.NewInvalidArgumentErrorf("property is not supported %s", key)
+		}
+	}
+
+	return p, errors.Join(scanner.Err(), ValidatePackageSpec(p))
+}
+
+// ValidatePackageSpec Arch package validation according to PKGBUILD specification.
+func ValidatePackageSpec(p *Package) error {
+	if !reName.MatchString(p.Name) {
+		return util.NewInvalidArgumentErrorf("invalid package name")
+	}
+	if !reName.MatchString(p.VersionMetadata.Base) {
+		return util.NewInvalidArgumentErrorf("invalid package base")
+	}
+	if !reVer.MatchString(p.Version) {
+		return util.NewInvalidArgumentErrorf("invalid package version")
+	}
+	if p.FileMetadata.Arch == "" {
+		return util.NewInvalidArgumentErrorf("architecture should be specified")
+	}
+	if p.VersionMetadata.ProjectURL != "" {
+		if !validation.IsValidURL(p.VersionMetadata.ProjectURL) {
+			return util.NewInvalidArgumentErrorf("invalid project URL")
+		}
+	}
+	for _, cd := range p.VersionMetadata.CheckDepends {
+		if !rePkgVer.MatchString(cd) {
+			return util.NewInvalidArgumentErrorf("invalid check dependency: " + cd)
+		}
+	}
+	for _, d := range p.VersionMetadata.Depends {
+		if !rePkgVer.MatchString(d) {
+			return util.NewInvalidArgumentErrorf("invalid dependency: " + d)
+		}
+	}
+	for _, md := range p.VersionMetadata.MakeDepends {
+		if !rePkgVer.MatchString(md) {
+			return util.NewInvalidArgumentErrorf("invalid make dependency: " + md)
+		}
+	}
+	for _, p := range p.VersionMetadata.Provides {
+		if !rePkgVer.MatchString(p) {
+			return util.NewInvalidArgumentErrorf("invalid provides: " + p)
+		}
+	}
+	for _, p := range p.VersionMetadata.Conflicts {
+		if !rePkgVer.MatchString(p) {
+			return util.NewInvalidArgumentErrorf("invalid conflicts: " + p)
+		}
+	}
+	for _, p := range p.VersionMetadata.Replaces {
+		if !rePkgVer.MatchString(p) {
+			return util.NewInvalidArgumentErrorf("invalid replaces: " + p)
+		}
+	}
+	for _, p := range p.VersionMetadata.Replaces {
+		if !rePkgVer.MatchString(p) {
+			return util.NewInvalidArgumentErrorf("invalid xdata: " + p)
+		}
+	}
+	for _, od := range p.VersionMetadata.OptDepends {
+		if !reOptDep.MatchString(od) {
+			return util.NewInvalidArgumentErrorf("invalid optional dependency: " + od)
+		}
+	}
+	for _, bf := range p.VersionMetadata.Backup {
+		if strings.HasPrefix(bf, "/") {
+			return util.NewInvalidArgumentErrorf("backup file contains leading forward slash")
+		}
+	}
+	return nil
+}
+
+// Desc Create pacman package description file.
+func (p *Package) Desc() string {
+	entries := []string{
+		"FILENAME", fmt.Sprintf("%s-%s-%s.pkg.tar.zst", p.Name, p.Version, p.FileMetadata.Arch),
+		"NAME", p.Name,
+		"BASE", p.VersionMetadata.Base,
+		"VERSION", p.Version,
+		"DESC", p.VersionMetadata.Description,
+		"GROUPS", strings.Join(p.VersionMetadata.Groups, "\n"),
+		"CSIZE", fmt.Sprintf("%d", p.FileMetadata.CompressedSize),
+		"ISIZE", fmt.Sprintf("%d", p.FileMetadata.InstalledSize),
+		"MD5SUM", p.FileMetadata.MD5,
+		"SHA256SUM", p.FileMetadata.SHA256,
+		"PGPSIG", p.FileMetadata.PgpSigned,
+		"URL", p.VersionMetadata.ProjectURL,
+		"LICENSE", strings.Join(p.VersionMetadata.License, "\n"),
+		"ARCH", p.FileMetadata.Arch,
+		"BUILDDATE", fmt.Sprintf("%d", p.FileMetadata.BuildDate),
+		"PACKAGER", p.FileMetadata.Packager,
+		"REPLACES", strings.Join(p.VersionMetadata.Replaces, "\n"),
+		"CONFLICTS", strings.Join(p.VersionMetadata.Conflicts, "\n"),
+		"PROVIDES", strings.Join(p.VersionMetadata.Provides, "\n"),
+		"DEPENDS", strings.Join(p.VersionMetadata.Depends, "\n"),
+		"OPTDEPENDS", strings.Join(p.VersionMetadata.OptDepends, "\n"),
+		"MAKEDEPENDS", strings.Join(p.VersionMetadata.MakeDepends, "\n"),
+		"CHECKDEPENDS", strings.Join(p.VersionMetadata.CheckDepends, "\n"),
+	}
+
+	var buf bytes.Buffer
+	for i := 0; i < len(entries); i += 2 {
+		if entries[i+1] != "" {
+			_, _ = fmt.Fprintf(&buf, "%%%s%%\n%s\n\n", entries[i], entries[i+1])
+		}
+	}
+	return buf.String()
+}
diff --git a/modules/packages/arch/metadata_test.go b/modules/packages/arch/metadata_test.go
new file mode 100644
index 0000000000..b084762fb6
--- /dev/null
+++ b/modules/packages/arch/metadata_test.go
@@ -0,0 +1,445 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package arch
+
+import (
+	"bytes"
+	"errors"
+	"os"
+	"strings"
+	"testing"
+	"testing/fstest"
+	"time"
+
+	"code.gitea.io/gitea/modules/packages"
+
+	"github.com/mholt/archiver/v3"
+	"github.com/stretchr/testify/require"
+)
+
+func TestParsePackage(t *testing.T) {
+	// Minimal PKGINFO contents and test FS
+	const PKGINFO = `pkgname = a
+pkgbase = b
+pkgver = 1-2
+arch = x86_64
+`
+	fs := fstest.MapFS{
+		"pkginfo": &fstest.MapFile{
+			Data:    []byte(PKGINFO),
+			Mode:    os.ModePerm,
+			ModTime: time.Now(),
+		},
+		"mtree": &fstest.MapFile{
+			Data:    []byte("data"),
+			Mode:    os.ModePerm,
+			ModTime: time.Now(),
+		},
+	}
+
+	// Test .PKGINFO file
+	pinf, err := fs.Stat("pkginfo")
+	require.NoError(t, err)
+
+	pfile, err := fs.Open("pkginfo")
+	require.NoError(t, err)
+
+	parcname, err := archiver.NameInArchive(pinf, ".PKGINFO", ".PKGINFO")
+	require.NoError(t, err)
+
+	// Test .MTREE file
+	minf, err := fs.Stat("mtree")
+	require.NoError(t, err)
+
+	mfile, err := fs.Open("mtree")
+	require.NoError(t, err)
+
+	marcname, err := archiver.NameInArchive(minf, ".MTREE", ".MTREE")
+	require.NoError(t, err)
+
+	t.Run("normal archive", func(t *testing.T) {
+		var buf bytes.Buffer
+
+		archive := archiver.NewTarZstd()
+		archive.Create(&buf)
+
+		err = archive.Write(archiver.File{
+			FileInfo: archiver.FileInfo{
+				FileInfo:   pinf,
+				CustomName: parcname,
+			},
+			ReadCloser: pfile,
+		})
+		require.NoError(t, errors.Join(pfile.Close(), err))
+
+		err = archive.Write(archiver.File{
+			FileInfo: archiver.FileInfo{
+				FileInfo:   minf,
+				CustomName: marcname,
+			},
+			ReadCloser: mfile,
+		})
+		require.NoError(t, errors.Join(mfile.Close(), archive.Close(), err))
+
+		reader, err := packages.CreateHashedBufferFromReader(&buf)
+		if err != nil {
+			t.Fatal(err)
+		}
+		defer reader.Close()
+		_, err = ParsePackage(reader)
+
+		require.NoError(t, err)
+	})
+
+	t.Run("missing .PKGINFO", func(t *testing.T) {
+		var buf bytes.Buffer
+
+		archive := archiver.NewTarZstd()
+		archive.Create(&buf)
+		require.NoError(t, archive.Close())
+
+		reader, err := packages.CreateHashedBufferFromReader(&buf)
+		require.NoError(t, err)
+
+		defer reader.Close()
+		_, err = ParsePackage(reader)
+
+		require.Error(t, err)
+		require.Contains(t, err.Error(), ".PKGINFO file not found")
+	})
+
+	t.Run("missing .MTREE", func(t *testing.T) {
+		var buf bytes.Buffer
+
+		pfile, err := fs.Open("pkginfo")
+		require.NoError(t, err)
+
+		archive := archiver.NewTarZstd()
+		archive.Create(&buf)
+
+		err = archive.Write(archiver.File{
+			FileInfo: archiver.FileInfo{
+				FileInfo:   pinf,
+				CustomName: parcname,
+			},
+			ReadCloser: pfile,
+		})
+		require.NoError(t, errors.Join(pfile.Close(), archive.Close(), err))
+		reader, err := packages.CreateHashedBufferFromReader(&buf)
+		require.NoError(t, err)
+
+		defer reader.Close()
+		_, err = ParsePackage(reader)
+
+		require.Error(t, err)
+		require.Contains(t, err.Error(), ".MTREE file not found")
+	})
+}
+
+func TestParsePackageInfo(t *testing.T) {
+	const PKGINFO = `# Generated by makepkg 6.0.2
+# using fakeroot version 1.31
+pkgname = a
+pkgbase = b
+pkgver = 1-2
+pkgdesc = comment
+url = https://example.com/
+group = group
+builddate = 3
+packager = Name Surname <login@example.com>
+size = 5
+arch = x86_64
+license = BSD
+provides = pvd
+depend = smth
+optdepend = hex
+checkdepend = ola
+makedepend = cmake
+backup = usr/bin/paket1
+`
+	p, err := ParsePackageInfo(strings.NewReader(PKGINFO))
+	require.NoError(t, err)
+	require.Equal(t, Package{
+		Name:    "a",
+		Version: "1-2",
+		VersionMetadata: VersionMetadata{
+			Base:         "b",
+			Description:  "comment",
+			ProjectURL:   "https://example.com/",
+			Groups:       []string{"group"},
+			Provides:     []string{"pvd"},
+			License:      []string{"BSD"},
+			Depends:      []string{"smth"},
+			OptDepends:   []string{"hex"},
+			MakeDepends:  []string{"cmake"},
+			CheckDepends: []string{"ola"},
+			Backup:       []string{"usr/bin/paket1"},
+		},
+		FileMetadata: FileMetadata{
+			InstalledSize: 5,
+			BuildDate:     3,
+			Packager:      "Name Surname <login@example.com>",
+			Arch:          "x86_64",
+		},
+	}, *p)
+}
+
+func TestValidatePackageSpec(t *testing.T) {
+	newpkg := func() Package {
+		return Package{
+			Name:    "abc",
+			Version: "1-1",
+			VersionMetadata: VersionMetadata{
+				Base:         "ghx",
+				Description:  "whoami",
+				ProjectURL:   "https://example.com/",
+				Groups:       []string{"gnome"},
+				Provides:     []string{"abc", "def"},
+				License:      []string{"GPL"},
+				Depends:      []string{"go", "gpg=1", "curl>=3", "git<=7"},
+				OptDepends:   []string{"git: something", "make"},
+				MakeDepends:  []string{"chrom"},
+				CheckDepends: []string{"bariy"},
+				Backup:       []string{"etc/pacman.d/filo"},
+			},
+			FileMetadata: FileMetadata{
+				CompressedSize: 1,
+				InstalledSize:  2,
+				SHA256:         "def",
+				BuildDate:      3,
+				Packager:       "smon",
+				Arch:           "x86_64",
+			},
+		}
+	}
+
+	t.Run("valid package", func(t *testing.T) {
+		p := newpkg()
+
+		err := ValidatePackageSpec(&p)
+
+		require.NoError(t, err)
+	})
+
+	t.Run("invalid package name", func(t *testing.T) {
+		p := newpkg()
+		p.Name = "!$%@^!*&()"
+
+		err := ValidatePackageSpec(&p)
+
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "invalid package name")
+	})
+
+	t.Run("invalid package base", func(t *testing.T) {
+		p := newpkg()
+		p.VersionMetadata.Base = "!$%@^!*&()"
+
+		err := ValidatePackageSpec(&p)
+
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "invalid package base")
+	})
+
+	t.Run("invalid package version", func(t *testing.T) {
+		p := newpkg()
+		p.VersionMetadata.Base = "una-luna?"
+
+		err := ValidatePackageSpec(&p)
+
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "invalid package base")
+	})
+
+	t.Run("invalid package version", func(t *testing.T) {
+		p := newpkg()
+		p.Version = "una-luna"
+
+		err := ValidatePackageSpec(&p)
+
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "invalid package version")
+	})
+
+	t.Run("missing architecture", func(t *testing.T) {
+		p := newpkg()
+		p.FileMetadata.Arch = ""
+
+		err := ValidatePackageSpec(&p)
+
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "architecture should be specified")
+	})
+
+	t.Run("invalid URL", func(t *testing.T) {
+		p := newpkg()
+		p.VersionMetadata.ProjectURL = "http%%$#"
+
+		err := ValidatePackageSpec(&p)
+
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "invalid project URL")
+	})
+
+	t.Run("invalid check dependency", func(t *testing.T) {
+		p := newpkg()
+		p.VersionMetadata.CheckDepends = []string{"Err^_^"}
+
+		err := ValidatePackageSpec(&p)
+
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "invalid check dependency")
+	})
+
+	t.Run("invalid dependency", func(t *testing.T) {
+		p := newpkg()
+		p.VersionMetadata.Depends = []string{"^^abc"}
+
+		err := ValidatePackageSpec(&p)
+
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "invalid dependency")
+	})
+
+	t.Run("invalid make dependency", func(t *testing.T) {
+		p := newpkg()
+		p.VersionMetadata.MakeDepends = []string{"^m^"}
+
+		err := ValidatePackageSpec(&p)
+
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "invalid make dependency")
+	})
+
+	t.Run("invalid provides", func(t *testing.T) {
+		p := newpkg()
+		p.VersionMetadata.Provides = []string{"^m^"}
+
+		err := ValidatePackageSpec(&p)
+
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "invalid provides")
+	})
+
+	t.Run("invalid optional dependency", func(t *testing.T) {
+		p := newpkg()
+		p.VersionMetadata.OptDepends = []string{"^m^:MM"}
+
+		err := ValidatePackageSpec(&p)
+
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "invalid optional dependency")
+	})
+
+	t.Run("invalid optional dependency", func(t *testing.T) {
+		p := newpkg()
+		p.VersionMetadata.Backup = []string{"/ola/cola"}
+
+		err := ValidatePackageSpec(&p)
+
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "backup file contains leading forward slash")
+	})
+}
+
+func TestDescString(t *testing.T) {
+	const pkgdesc = `%FILENAME%
+zstd-1.5.5-1-x86_64.pkg.tar.zst
+
+%NAME%
+zstd
+
+%BASE%
+zstd
+
+%VERSION%
+1.5.5-1
+
+%DESC%
+Zstandard - Fast real-time compression algorithm
+
+%GROUPS%
+dummy1
+dummy2
+
+%CSIZE%
+401
+
+%ISIZE%
+1500453
+
+%MD5SUM%
+5016660ef3d9aa148a7b72a08d3df1b2
+
+%SHA256SUM%
+9fa4ede47e35f5971e4f26ecadcbfb66ab79f1d638317ac80334a3362dedbabd
+
+%URL%
+https://facebook.github.io/zstd/
+
+%LICENSE%
+BSD
+GPL2
+
+%ARCH%
+x86_64
+
+%BUILDDATE%
+1681646714
+
+%PACKAGER%
+Jelle van der Waa <jelle@archlinux.org>
+
+%PROVIDES%
+libzstd.so=1-64
+
+%DEPENDS%
+glibc
+gcc-libs
+zlib
+xz
+lz4
+
+%OPTDEPENDS%
+dummy3
+dummy4
+
+%MAKEDEPENDS%
+cmake
+gtest
+ninja
+
+%CHECKDEPENDS%
+dummy5
+dummy6
+
+`
+
+	md := &Package{
+		Name:    "zstd",
+		Version: "1.5.5-1",
+		VersionMetadata: VersionMetadata{
+			Base:         "zstd",
+			Description:  "Zstandard - Fast real-time compression algorithm",
+			ProjectURL:   "https://facebook.github.io/zstd/",
+			Groups:       []string{"dummy1", "dummy2"},
+			Provides:     []string{"libzstd.so=1-64"},
+			License:      []string{"BSD", "GPL2"},
+			Depends:      []string{"glibc", "gcc-libs", "zlib", "xz", "lz4"},
+			OptDepends:   []string{"dummy3", "dummy4"},
+			MakeDepends:  []string{"cmake", "gtest", "ninja"},
+			CheckDepends: []string{"dummy5", "dummy6"},
+		},
+		FileMetadata: FileMetadata{
+			CompressedSize: 401,
+			InstalledSize:  1500453,
+			MD5:            "5016660ef3d9aa148a7b72a08d3df1b2",
+			SHA256:         "9fa4ede47e35f5971e4f26ecadcbfb66ab79f1d638317ac80334a3362dedbabd",
+			BuildDate:      1681646714,
+			Packager:       "Jelle van der Waa <jelle@archlinux.org>",
+			Arch:           "x86_64",
+		},
+	}
+	require.Equal(t, pkgdesc, md.Desc())
+}
diff --git a/modules/setting/packages.go b/modules/setting/packages.go
index 50263546ce..b3f50617d2 100644
--- a/modules/setting/packages.go
+++ b/modules/setting/packages.go
@@ -24,6 +24,7 @@ var (
 		LimitTotalOwnerCount  int64
 		LimitTotalOwnerSize   int64
 		LimitSizeAlpine       int64
+		LimitSizeArch         int64
 		LimitSizeCargo        int64
 		LimitSizeChef         int64
 		LimitSizeComposer     int64
@@ -83,6 +84,7 @@ func loadPackagesFrom(rootCfg ConfigProvider) (err error) {
 
 	Packages.LimitTotalOwnerSize = mustBytes(sec, "LIMIT_TOTAL_OWNER_SIZE")
 	Packages.LimitSizeAlpine = mustBytes(sec, "LIMIT_SIZE_ALPINE")
+	Packages.LimitSizeArch = mustBytes(sec, "LIMIT_SIZE_ARCH")
 	Packages.LimitSizeCargo = mustBytes(sec, "LIMIT_SIZE_CARGO")
 	Packages.LimitSizeChef = mustBytes(sec, "LIMIT_SIZE_CHEF")
 	Packages.LimitSizeComposer = mustBytes(sec, "LIMIT_SIZE_COMPOSER")
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 8737342448..d0a0dc0696 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -3611,6 +3611,22 @@ alpine.repository = Repository Info
 alpine.repository.branches = Branches
 alpine.repository.repositories = Repositories
 alpine.repository.architectures = Architectures
+arch.pacman.helper.gpg = Add trust certificate for pacman:
+arch.pacman.repo.multi = %s has the same version in different distributions.
+arch.pacman.repo.multi.item = Configuration for %s
+arch.pacman.conf = Add server with related distribution and architecture to <code>/etc/pacman.conf</code> :
+arch.pacman.sync = Sync package with pacman:
+arch.version.properties = Version Properties
+arch.version.description = Description
+arch.version.provides = Provides
+arch.version.groups = Group
+arch.version.depends = Depends
+arch.version.optdepends = Optional depends
+arch.version.makedepends = Make depends
+arch.version.checkdepends = Check depends
+arch.version.conflicts = Conflicts
+arch.version.replaces = Replaces
+arch.version.backup = Backup
 cargo.registry = Setup this registry in the Cargo configuration file (for example <code>~/.cargo/config.toml</code>):
 cargo.install = To install the package using Cargo, run the following command:
 chef.registry = Setup this registry in your <code>~/.chef/config.rb</code> file:
diff --git a/public/assets/img/svg/gitea-arch.svg b/public/assets/img/svg/gitea-arch.svg
new file mode 100644
index 0000000000..943a92c579
--- /dev/null
+++ b/public/assets/img/svg/gitea-arch.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="svg gitea-arch" width="16" height="16" aria-hidden="true"><path fill="#1793d1" d="M256 72c-14 35-23 57-39 91 10 11 22 23 41 36-21-8-35-17-45-26-21 43-53 103-117 220 50-30 90-48 127-55-2-7-3-14-3-22v-1c1-33 18-58 38-56 20 1 36 29 35 62l-2 17c36 7 75 26 125 54l-27-50c-13-10-27-23-55-38 19 5 33 11 44 17-86-159-93-180-122-250z"/></svg>
\ No newline at end of file
diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go
index f590947111..e13bd1e862 100644
--- a/routers/api/packages/api.go
+++ b/routers/api/packages/api.go
@@ -15,6 +15,7 @@ import (
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/web"
 	"code.gitea.io/gitea/routers/api/packages/alpine"
+	"code.gitea.io/gitea/routers/api/packages/arch"
 	"code.gitea.io/gitea/routers/api/packages/cargo"
 	"code.gitea.io/gitea/routers/api/packages/chef"
 	"code.gitea.io/gitea/routers/api/packages/composer"
@@ -137,6 +138,17 @@ func CommonRoutes() *web.Route {
 				})
 			})
 		}, reqPackageAccess(perm.AccessModeRead))
+		r.Group("/arch", func() {
+			r.Group("/repository.key", func() {
+				r.Head("", arch.GetRepositoryKey)
+				r.Get("", arch.GetRepositoryKey)
+			})
+			r.Group("/{distro}", func() {
+				r.Put("", reqPackageAccess(perm.AccessModeWrite), arch.PushPackage)
+				r.Get("/{arch}/{file}", arch.GetPackageOrDB)
+				r.Delete("/{package}/{version}", reqPackageAccess(perm.AccessModeWrite), arch.RemovePackage)
+			})
+		}, reqPackageAccess(perm.AccessModeRead))
 		r.Group("/cargo", func() {
 			r.Group("/api/v1/crates", func() {
 				r.Get("", cargo.SearchPackages)
diff --git a/routers/api/packages/arch/arch.go b/routers/api/packages/arch/arch.go
new file mode 100644
index 0000000000..a01b496d41
--- /dev/null
+++ b/routers/api/packages/arch/arch.go
@@ -0,0 +1,248 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package arch
+
+import (
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+
+	packages_model "code.gitea.io/gitea/models/packages"
+	packages_module "code.gitea.io/gitea/modules/packages"
+	arch_module "code.gitea.io/gitea/modules/packages/arch"
+	"code.gitea.io/gitea/modules/util"
+	"code.gitea.io/gitea/routers/api/packages/helper"
+	"code.gitea.io/gitea/services/context"
+	packages_service "code.gitea.io/gitea/services/packages"
+	arch_service "code.gitea.io/gitea/services/packages/arch"
+)
+
+func apiError(ctx *context.Context, status int, obj any) {
+	helper.LogAndProcessError(ctx, status, obj, func(message string) {
+		ctx.PlainText(status, message)
+	})
+}
+
+func GetRepositoryKey(ctx *context.Context) {
+	_, pub, err := arch_service.GetOrCreateKeyPair(ctx, ctx.Package.Owner.ID)
+	if err != nil {
+		apiError(ctx, http.StatusInternalServerError, err)
+		return
+	}
+
+	ctx.ServeContent(strings.NewReader(pub), &context.ServeHeaderOptions{
+		ContentType: "application/pgp-keys",
+		Filename:    "repository.key",
+	})
+}
+
+func PushPackage(ctx *context.Context) {
+	distro := ctx.Params("distro")
+
+	upload, needToClose, err := ctx.UploadStream()
+	if err != nil {
+		apiError(ctx, http.StatusInternalServerError, err)
+		return
+	}
+	if needToClose {
+		defer upload.Close()
+	}
+
+	buf, err := packages_module.CreateHashedBufferFromReader(upload)
+	if err != nil {
+		apiError(ctx, http.StatusInternalServerError, err)
+		return
+	}
+	defer buf.Close()
+
+	p, err := arch_module.ParsePackage(buf)
+	if err != nil {
+		apiError(ctx, http.StatusInternalServerError, err)
+		return
+	}
+
+	_, err = buf.Seek(0, io.SeekStart)
+	if err != nil {
+		apiError(ctx, http.StatusInternalServerError, err)
+		return
+	}
+	sign, err := arch_service.NewFileSign(ctx, ctx.Package.Owner.ID, buf)
+	if err != nil {
+		apiError(ctx, http.StatusInternalServerError, err)
+		return
+	}
+	defer sign.Close()
+	_, err = buf.Seek(0, io.SeekStart)
+	if err != nil {
+		apiError(ctx, http.StatusInternalServerError, err)
+		return
+	}
+	// update gpg sign
+	pgp, err := io.ReadAll(sign)
+	if err != nil {
+		apiError(ctx, http.StatusInternalServerError, err)
+		return
+	}
+	p.FileMetadata.PgpSigned = base64.StdEncoding.EncodeToString(pgp)
+	_, err = sign.Seek(0, io.SeekStart)
+	if err != nil {
+		apiError(ctx, http.StatusInternalServerError, err)
+		return
+	}
+
+	properties := map[string]string{
+		arch_module.PropertyDescription:  p.Desc(),
+		arch_module.PropertyArch:         p.FileMetadata.Arch,
+		arch_module.PropertyDistribution: distro,
+	}
+
+	version, _, err := packages_service.CreatePackageOrAddFileToExisting(
+		ctx,
+		&packages_service.PackageCreationInfo{
+			PackageInfo: packages_service.PackageInfo{
+				Owner:       ctx.Package.Owner,
+				PackageType: packages_model.TypeArch,
+				Name:        p.Name,
+				Version:     p.Version,
+			},
+			Creator:  ctx.Doer,
+			Metadata: p.VersionMetadata,
+		},
+		&packages_service.PackageFileCreationInfo{
+			PackageFileInfo: packages_service.PackageFileInfo{
+				Filename:     fmt.Sprintf("%s-%s-%s.pkg.tar.zst", p.Name, p.Version, p.FileMetadata.Arch),
+				CompositeKey: distro,
+			},
+			OverwriteExisting: false,
+			IsLead:            true,
+			Creator:           ctx.ContextUser,
+			Data:              buf,
+			Properties:        properties,
+		},
+	)
+	if err != nil {
+		switch {
+		case errors.Is(err, packages_model.ErrDuplicatePackageVersion), errors.Is(err, packages_model.ErrDuplicatePackageFile):
+			apiError(ctx, http.StatusConflict, err)
+		case errors.Is(err, packages_service.ErrQuotaTotalCount), errors.Is(err, packages_service.ErrQuotaTypeSize), errors.Is(err, packages_service.ErrQuotaTotalSize):
+			apiError(ctx, http.StatusForbidden, err)
+		default:
+			apiError(ctx, http.StatusInternalServerError, err)
+		}
+		return
+	}
+	// add sign file
+	_, err = packages_service.AddFileToPackageVersionInternal(ctx, version, &packages_service.PackageFileCreationInfo{
+		PackageFileInfo: packages_service.PackageFileInfo{
+			CompositeKey: distro,
+			Filename:     fmt.Sprintf("%s-%s-%s.pkg.tar.zst.sig", p.Name, p.Version, p.FileMetadata.Arch),
+		},
+		OverwriteExisting: true,
+		IsLead:            false,
+		Creator:           ctx.Doer,
+		Data:              sign,
+	})
+	if err != nil {
+		apiError(ctx, http.StatusInternalServerError, err)
+	}
+	if err = arch_service.BuildPacmanDB(ctx, ctx.Package.Owner.ID, distro, p.FileMetadata.Arch); err != nil {
+		apiError(ctx, http.StatusInternalServerError, err)
+		return
+	}
+	ctx.Status(http.StatusCreated)
+}
+
+func GetPackageOrDB(ctx *context.Context) {
+	var (
+		file   = ctx.Params("file")
+		distro = ctx.Params("distro")
+		arch   = ctx.Params("arch")
+	)
+
+	if strings.HasSuffix(file, ".pkg.tar.zst") || strings.HasSuffix(file, ".pkg.tar.zst.sig") {
+		pkg, err := arch_service.GetPackageFile(ctx, distro, file, ctx.Package.Owner.ID)
+		if err != nil {
+			if errors.Is(err, util.ErrNotExist) {
+				apiError(ctx, http.StatusNotFound, err)
+			} else {
+				apiError(ctx, http.StatusInternalServerError, err)
+			}
+			return
+		}
+
+		ctx.ServeContent(pkg, &context.ServeHeaderOptions{
+			Filename: file,
+		})
+		return
+	}
+
+	if strings.HasSuffix(file, ".db.tar.gz") ||
+		strings.HasSuffix(file, ".db") ||
+		strings.HasSuffix(file, ".db.tar.gz.sig") ||
+		strings.HasSuffix(file, ".db.sig") {
+		pkg, err := arch_service.GetPackageDBFile(ctx, distro, arch, ctx.Package.Owner.ID,
+			strings.HasSuffix(file, ".sig"))
+		if err != nil {
+			if errors.Is(err, util.ErrNotExist) {
+				apiError(ctx, http.StatusNotFound, err)
+			} else {
+				apiError(ctx, http.StatusInternalServerError, err)
+			}
+			return
+		}
+		ctx.ServeContent(pkg, &context.ServeHeaderOptions{
+			Filename: file,
+		})
+		return
+	}
+
+	ctx.Status(http.StatusNotFound)
+}
+
+func RemovePackage(ctx *context.Context) {
+	var (
+		distro = ctx.Params("distro")
+		pkg    = ctx.Params("package")
+		ver    = ctx.Params("version")
+	)
+	pv, err := packages_model.GetVersionByNameAndVersion(
+		ctx, ctx.Package.Owner.ID, packages_model.TypeArch, pkg, ver,
+	)
+	if err != nil {
+		if errors.Is(err, util.ErrNotExist) {
+			apiError(ctx, http.StatusNotFound, err)
+		} else {
+			apiError(ctx, http.StatusInternalServerError, err)
+		}
+		return
+	}
+	files, err := packages_model.GetFilesByVersionID(ctx, pv.ID)
+	if err != nil {
+		apiError(ctx, http.StatusInternalServerError, err)
+		return
+	}
+	deleted := false
+	for _, file := range files {
+		if file.CompositeKey == distro {
+			deleted = true
+			err := packages_service.RemovePackageFileAndVersionIfUnreferenced(ctx, ctx.ContextUser, file)
+			if err != nil {
+				apiError(ctx, http.StatusInternalServerError, err)
+				return
+			}
+		}
+	}
+	if deleted {
+		err = arch_service.BuildCustomRepositoryFiles(ctx, ctx.Package.Owner.ID, distro)
+		if err != nil {
+			apiError(ctx, http.StatusInternalServerError, err)
+		}
+		ctx.Status(http.StatusNoContent)
+	} else {
+		ctx.Error(http.StatusNotFound)
+	}
+}
diff --git a/routers/web/user/package.go b/routers/web/user/package.go
index 3ecc59a2ab..204efe6001 100644
--- a/routers/web/user/package.go
+++ b/routers/web/user/package.go
@@ -4,6 +4,7 @@
 package user
 
 import (
+	"fmt"
 	"net/http"
 
 	"code.gitea.io/gitea/models/db"
@@ -18,6 +19,7 @@ import (
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/optional"
 	alpine_module "code.gitea.io/gitea/modules/packages/alpine"
+	arch_model "code.gitea.io/gitea/modules/packages/arch"
 	debian_module "code.gitea.io/gitea/modules/packages/debian"
 	rpm_module "code.gitea.io/gitea/modules/packages/rpm"
 	"code.gitea.io/gitea/modules/setting"
@@ -200,6 +202,19 @@ func ViewPackageVersion(ctx *context.Context) {
 		ctx.Data["Branches"] = util.Sorted(branches.Values())
 		ctx.Data["Repositories"] = util.Sorted(repositories.Values())
 		ctx.Data["Architectures"] = util.Sorted(architectures.Values())
+	case packages_model.TypeArch:
+		ctx.Data["RegistryHost"] = setting.Packages.RegistryHost
+		ctx.Data["SignMail"] = fmt.Sprintf("%s@noreply.%s", ctx.Package.Owner.Name, setting.Packages.RegistryHost)
+		groups := make(container.Set[string])
+		for _, f := range pd.Files {
+			for _, pp := range f.Properties {
+				switch pp.Name {
+				case arch_model.PropertyDistribution:
+					groups.Add(pp.Value)
+				}
+			}
+		}
+		ctx.Data["Groups"] = util.Sorted(groups.Values())
 	case packages_model.TypeDebian:
 		distributions := make(container.Set[string])
 		components := make(container.Set[string])
diff --git a/services/packages/arch/repository.go b/services/packages/arch/repository.go
new file mode 100644
index 0000000000..acd002dcc8
--- /dev/null
+++ b/services/packages/arch/repository.go
@@ -0,0 +1,348 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package arch
+
+import (
+	"archive/tar"
+	"compress/gzip"
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"sort"
+	"strings"
+
+	packages_model "code.gitea.io/gitea/models/packages"
+	user_model "code.gitea.io/gitea/models/user"
+	packages_module "code.gitea.io/gitea/modules/packages"
+	arch_module "code.gitea.io/gitea/modules/packages/arch"
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/util"
+	packages_service "code.gitea.io/gitea/services/packages"
+
+	"github.com/ProtonMail/go-crypto/openpgp"
+	"github.com/ProtonMail/go-crypto/openpgp/armor"
+	"github.com/ProtonMail/go-crypto/openpgp/packet"
+)
+
+func GetOrCreateRepositoryVersion(ctx context.Context, ownerID int64) (*packages_model.PackageVersion, error) {
+	return packages_service.GetOrCreateInternalPackageVersion(ctx, ownerID, packages_model.TypeArch, arch_module.RepositoryPackage, arch_module.RepositoryVersion)
+}
+
+func BuildAllRepositoryFiles(ctx context.Context, ownerID int64) error {
+	pv, err := GetOrCreateRepositoryVersion(ctx, ownerID)
+	if err != nil {
+		return err
+	}
+	// remove old db files
+	pfs, err := packages_model.GetFilesByVersionID(ctx, pv.ID)
+	if err != nil {
+		return err
+	}
+	for _, pf := range pfs {
+		if strings.HasSuffix(pf.Name, ".db") {
+			arch := strings.TrimSuffix(strings.TrimPrefix(pf.Name, fmt.Sprintf("%s-", pf.CompositeKey)), ".db")
+			if err := BuildPacmanDB(ctx, ownerID, pf.CompositeKey, arch); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+func BuildCustomRepositoryFiles(ctx context.Context, ownerID int64, disco string) error {
+	pv, err := GetOrCreateRepositoryVersion(ctx, ownerID)
+	if err != nil {
+		return err
+	}
+	// remove old db files
+	pfs, err := packages_model.GetFilesByVersionID(ctx, pv.ID)
+	if err != nil {
+		return err
+	}
+	for _, pf := range pfs {
+		if strings.HasSuffix(pf.Name, ".db") && pf.CompositeKey == disco {
+			arch := strings.TrimSuffix(strings.TrimPrefix(pf.Name, fmt.Sprintf("%s-", pf.CompositeKey)), ".db")
+			if err := BuildPacmanDB(ctx, ownerID, pf.CompositeKey, arch); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+func NewFileSign(ctx context.Context, ownerID int64, input io.Reader) (*packages_module.HashedBuffer, error) {
+	// If no signature is specified, it will be generated by Gitea.
+	priv, _, err := GetOrCreateKeyPair(ctx, ownerID)
+	if err != nil {
+		return nil, err
+	}
+	block, err := armor.Decode(strings.NewReader(priv))
+	if err != nil {
+		return nil, err
+	}
+	e, err := openpgp.ReadEntity(packet.NewReader(block.Body))
+	if err != nil {
+		return nil, err
+	}
+	pkgSig, err := packages_module.NewHashedBuffer()
+	if err != nil {
+		return nil, err
+	}
+	defer pkgSig.Close()
+	if err := openpgp.DetachSign(pkgSig, e, input, nil); err != nil {
+		return nil, err
+	}
+	return pkgSig, nil
+}
+
+// BuildPacmanDB Create db signature cache
+func BuildPacmanDB(ctx context.Context, ownerID int64, distro, arch string) error {
+	pv, err := GetOrCreateRepositoryVersion(ctx, ownerID)
+	if err != nil {
+		return err
+	}
+	// remove old db files
+	pfs, err := packages_model.GetFilesByVersionID(ctx, pv.ID)
+	if err != nil {
+		return err
+	}
+	for _, pf := range pfs {
+		if pf.CompositeKey == distro && strings.HasPrefix(pf.Name, fmt.Sprintf("%s-%s", distro, arch)) {
+			// remove distro and arch
+			if err := packages_service.DeletePackageFile(ctx, pf); err != nil {
+				return err
+			}
+		}
+	}
+
+	db, err := flushDB(ctx, ownerID, distro, arch)
+	if errors.Is(err, io.EOF) {
+		return nil
+	} else if err != nil {
+		return err
+	}
+	defer db.Close()
+	// Create db signature cache
+	_, err = db.Seek(0, io.SeekStart)
+	if err != nil {
+		return err
+	}
+	sig, err := NewFileSign(ctx, ownerID, db)
+	if err != nil {
+		return err
+	}
+	defer sig.Close()
+	_, err = db.Seek(0, io.SeekStart)
+	if err != nil {
+		return err
+	}
+	for name, data := range map[string]*packages_module.HashedBuffer{
+		fmt.Sprintf("%s-%s.db", distro, arch):     db,
+		fmt.Sprintf("%s-%s.db.sig", distro, arch): sig,
+	} {
+		_, err = packages_service.AddFileToPackageVersionInternal(ctx, pv, &packages_service.PackageFileCreationInfo{
+			PackageFileInfo: packages_service.PackageFileInfo{
+				Filename:     name,
+				CompositeKey: distro,
+			},
+			Creator:           user_model.NewGhostUser(),
+			Data:              data,
+			IsLead:            false,
+			OverwriteExisting: true,
+		})
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func flushDB(ctx context.Context, ownerID int64, distro, arch string) (*packages_module.HashedBuffer, error) {
+	pkgs, err := packages_model.GetPackagesByType(ctx, ownerID, packages_model.TypeArch)
+	if err != nil {
+		return nil, err
+	}
+	if len(pkgs) == 0 {
+		return nil, io.EOF
+	}
+	db, err := packages_module.NewHashedBuffer()
+	if err != nil {
+		return nil, err
+	}
+	gw := gzip.NewWriter(db)
+	tw := tar.NewWriter(gw)
+	count := 0
+	for _, pkg := range pkgs {
+		versions, err := packages_model.GetVersionsByPackageName(
+			ctx, ownerID, packages_model.TypeArch, pkg.Name,
+		)
+		if err != nil {
+			return nil, errors.Join(tw.Close(), gw.Close(), db.Close(), err)
+		}
+		sort.Slice(versions, func(i, j int) bool {
+			return versions[i].CreatedUnix > versions[j].CreatedUnix
+		})
+		for _, ver := range versions {
+			file := fmt.Sprintf("%s-%s-%s.pkg.tar.zst", pkg.Name, ver.Version, arch)
+			pf, err := packages_model.GetFileForVersionByName(ctx, ver.ID, file, distro)
+			if err != nil {
+				// add any arch package
+				file = fmt.Sprintf("%s-%s-any.pkg.tar.zst", pkg.Name, ver.Version)
+				pf, err = packages_model.GetFileForVersionByName(ctx, ver.ID, file, distro)
+				if err != nil {
+					continue
+				}
+			}
+			pps, err := packages_model.GetPropertiesByName(
+				ctx, packages_model.PropertyTypeFile, pf.ID, arch_module.PropertyDescription,
+			)
+			if err != nil {
+				return nil, errors.Join(tw.Close(), gw.Close(), db.Close(), err)
+			}
+			if len(pps) >= 1 {
+				meta := []byte(pps[0].Value)
+				header := &tar.Header{
+					Name: pkg.Name + "-" + ver.Version + "/desc",
+					Size: int64(len(meta)),
+					Mode: int64(os.ModePerm),
+				}
+				if err = tw.WriteHeader(header); err != nil {
+					return nil, errors.Join(tw.Close(), gw.Close(), db.Close(), err)
+				}
+				if _, err := tw.Write(meta); err != nil {
+					return nil, errors.Join(tw.Close(), gw.Close(), db.Close(), err)
+				}
+				count++
+				break
+			}
+		}
+	}
+	defer gw.Close()
+	defer tw.Close()
+	if count == 0 {
+		return nil, errors.Join(db.Close(), io.EOF)
+	}
+	return db, nil
+}
+
+// GetPackageFile Get data related to provided filename and distribution, for package files
+// update download counter.
+func GetPackageFile(ctx context.Context, distro, file string, ownerID int64) (io.ReadSeekCloser, error) {
+	pf, err := getPackageFile(ctx, distro, file, ownerID)
+	if err != nil {
+		return nil, err
+	}
+
+	filestream, _, _, err := packages_service.GetPackageFileStream(ctx, pf)
+	return filestream, err
+}
+
+// Ejects parameters required to get package file property from file name.
+func getPackageFile(ctx context.Context, distro, file string, ownerID int64) (*packages_model.PackageFile, error) {
+	var (
+		splt    = strings.Split(file, "-")
+		pkgname = strings.Join(splt[0:len(splt)-3], "-")
+		vername = splt[len(splt)-3] + "-" + splt[len(splt)-2]
+	)
+
+	version, err := packages_model.GetVersionByNameAndVersion(ctx, ownerID, packages_model.TypeArch, pkgname, vername)
+	if err != nil {
+		return nil, err
+	}
+
+	pkgfile, err := packages_model.GetFileForVersionByName(ctx, version.ID, file, distro)
+	if err != nil {
+		return nil, err
+	}
+	return pkgfile, nil
+}
+
+func GetPackageDBFile(ctx context.Context, distro, arch string, ownerID int64, signFile bool) (io.ReadSeekCloser, error) {
+	pv, err := GetOrCreateRepositoryVersion(ctx, ownerID)
+	if err != nil {
+		return nil, err
+	}
+	fileName := fmt.Sprintf("%s-%s.db", distro, arch)
+	if signFile {
+		fileName = fmt.Sprintf("%s-%s.db.sig", distro, arch)
+	}
+	file, err := packages_model.GetFileForVersionByName(ctx, pv.ID, fileName, distro)
+	if err != nil {
+		return nil, err
+	}
+	filestream, _, _, err := packages_service.GetPackageFileStream(ctx, file)
+	return filestream, err
+}
+
+// GetOrCreateKeyPair gets or creates the PGP keys used to sign repository metadata files
+func GetOrCreateKeyPair(ctx context.Context, ownerID int64) (string, string, error) {
+	priv, err := user_model.GetSetting(ctx, ownerID, arch_module.SettingKeyPrivate)
+	if err != nil && !errors.Is(err, util.ErrNotExist) {
+		return "", "", err
+	}
+
+	pub, err := user_model.GetSetting(ctx, ownerID, arch_module.SettingKeyPublic)
+	if err != nil && !errors.Is(err, util.ErrNotExist) {
+		return "", "", err
+	}
+
+	if priv == "" || pub == "" {
+		user, err := user_model.GetUserByID(ctx, ownerID)
+		if err != nil && !errors.Is(err, util.ErrNotExist) {
+			return "", "", err
+		}
+
+		priv, pub, err = generateKeypair(user.Name)
+		if err != nil {
+			return "", "", err
+		}
+
+		if err := user_model.SetUserSetting(ctx, ownerID, arch_module.SettingKeyPrivate, priv); err != nil {
+			return "", "", err
+		}
+
+		if err := user_model.SetUserSetting(ctx, ownerID, arch_module.SettingKeyPublic, pub); err != nil {
+			return "", "", err
+		}
+	}
+
+	return priv, pub, nil
+}
+
+func generateKeypair(owner string) (string, string, error) {
+	e, err := openpgp.NewEntity(
+		owner,
+		"Arch Package signature only",
+		fmt.Sprintf("%s@noreply.%s", owner, setting.Packages.RegistryHost), &packet.Config{
+			RSABits: 4096,
+		})
+	if err != nil {
+		return "", "", err
+	}
+
+	var priv strings.Builder
+	var pub strings.Builder
+
+	w, err := armor.Encode(&priv, openpgp.PrivateKeyType, nil)
+	if err != nil {
+		return "", "", err
+	}
+	if err := e.SerializePrivate(w, nil); err != nil {
+		return "", "", err
+	}
+	w.Close()
+
+	w, err = armor.Encode(&pub, openpgp.PublicKeyType, nil)
+	if err != nil {
+		return "", "", err
+	}
+	if err := e.Serialize(w); err != nil {
+		return "", "", err
+	}
+	w.Close()
+
+	return priv.String(), pub.String(), nil
+}
diff --git a/services/packages/cleanup/cleanup.go b/services/packages/cleanup/cleanup.go
index 5aba730996..ab419a9a5a 100644
--- a/services/packages/cleanup/cleanup.go
+++ b/services/packages/cleanup/cleanup.go
@@ -16,6 +16,7 @@ import (
 	packages_module "code.gitea.io/gitea/modules/packages"
 	packages_service "code.gitea.io/gitea/services/packages"
 	alpine_service "code.gitea.io/gitea/services/packages/alpine"
+	arch_service "code.gitea.io/gitea/services/packages/arch"
 	cargo_service "code.gitea.io/gitea/services/packages/cargo"
 	container_service "code.gitea.io/gitea/services/packages/container"
 	debian_service "code.gitea.io/gitea/services/packages/debian"
@@ -132,6 +133,10 @@ func ExecuteCleanupRules(outerCtx context.Context) error {
 				if err := rpm_service.BuildAllRepositoryFiles(ctx, pcr.OwnerID); err != nil {
 					return fmt.Errorf("CleanupRule [%d]: rpm.BuildAllRepositoryFiles failed: %w", pcr.ID, err)
 				}
+			} else if pcr.Type == packages_model.TypeArch {
+				if err := arch_service.BuildAllRepositoryFiles(ctx, pcr.OwnerID); err != nil {
+					return fmt.Errorf("CleanupRule [%d]: arch.BuildAllRepositoryFiles failed: %w", pcr.ID, err)
+				}
 			}
 		}
 		return nil
diff --git a/services/packages/packages.go b/services/packages/packages.go
index 8f688a74f4..a5b84506de 100644
--- a/services/packages/packages.go
+++ b/services/packages/packages.go
@@ -359,6 +359,8 @@ func CheckSizeQuotaExceeded(ctx context.Context, doer, owner *user_model.User, p
 	switch packageType {
 	case packages_model.TypeAlpine:
 		typeSpecificSize = setting.Packages.LimitSizeAlpine
+	case packages_model.TypeArch:
+		typeSpecificSize = setting.Packages.LimitSizeArch
 	case packages_model.TypeCargo:
 		typeSpecificSize = setting.Packages.LimitSizeCargo
 	case packages_model.TypeChef:
diff --git a/templates/package/content/arch.tmpl b/templates/package/content/arch.tmpl
new file mode 100644
index 0000000000..bcc24b585b
--- /dev/null
+++ b/templates/package/content/arch.tmpl
@@ -0,0 +1,143 @@
+{{if eq .PackageDescriptor.Package.Type "arch"}}
+<h4 class="ui top attached header">{{ctx.Locale.Tr "packages.installation"}}</h4>
+<div class="ui attached segment">
+	<div class="ui form">
+		<div class="field">
+			<label>{{svg "octicon-terminal"}} {{ctx.Locale.Tr "packages.arch.pacman.helper.gpg"}}</label>
+			<div class="markup">
+				<pre class="code-block"><code>wget -O sign.gpg <origin-url data-url="{{AppSubUrl}}/api/packages/{{.PackageDescriptor.Owner.Name}}/arch/repository.key"></origin-url>
+pacman-key --add sign.gpg
+pacman-key --lsign-key '{{$.SignMail}}'</code></pre>
+			</div>
+		</div>
+		<div class="field">
+			<label>{{svg "octicon-gear"}} {{ctx.Locale.Tr "packages.arch.pacman.conf"}}</label>
+			<div class="markup">
+				<pre
+					class="code-block"><code>
+{{- if gt (len $.Groups) 1 -}}
+# {{ctx.Locale.Tr "packages.arch.pacman.repo.multi"  $.PackageDescriptor.Package.LowerName}}
+
+{{end -}}
+{{- $GroupSize := (len .Groups) -}}
+{{-  range $i,$v :=  .Groups -}}
+{{- if gt $i 0}}
+{{end -}}{{- if gt $GroupSize 1 -}}
+# {{ctx.Locale.Tr "packages.arch.pacman.repo.multi.item" .}}
+{{end -}}
+[{{$.PackageDescriptor.Owner.LowerName}}.{{$.RegistryHost}}]
+SigLevel = Required
+Server = <origin-url data-url="{{AppSubUrl}}/api/packages/{{$.PackageDescriptor.Owner.Name}}/arch/{{.}}/$arch"></origin-url>
+{{end -}}
+</code></pre>
+			</div>
+		</div>
+		<div class="field">
+			<label>{{svg "octicon-sync"}} {{ctx.Locale.Tr "packages.arch.pacman.sync"}}</label>
+			<div class="markup">
+				<pre class="code-block"><code>pacman -Sy {{.PackageDescriptor.Package.LowerName}}</code></pre>
+			</div>
+		</div>
+		<div class="field">
+			<label>{{ctx.Locale.Tr "packages.registry.documentation" "Arch"
+				"https://forgejo.org/docs/latest/user/packages/arch/"}}</label>
+		</div>
+	</div>
+</div>
+
+<h4 class="ui top attached header">{{ctx.Locale.Tr "packages.arch.version.properties"}}</h4>
+<div class="ui attached segment">
+	<table class="ui very basic compact table">
+		<tbody>
+			<tr>
+				<td class="collapsing">
+					<h5>{{ctx.Locale.Tr "packages.arch.version.description"}}</h5>
+				</td>
+				<td>{{.PackageDescriptor.Metadata.Description}}</td>
+			</tr>
+
+			{{if .PackageDescriptor.Metadata.Groups}}
+			<tr>
+				<td class="collapsing">
+					<h5>{{ctx.Locale.Tr "packages.arch.version.groups"}}</h5>
+				</td>
+				<td>{{StringUtils.Join $.PackageDescriptor.Metadata.Groups ", "}}</td>
+			</tr>
+			{{end}}
+
+			{{if .PackageDescriptor.Metadata.Provides}}
+			<tr>
+				<td class="collapsing">
+					<h5>{{ctx.Locale.Tr "packages.arch.version.provides"}}</h5>
+				</td>
+				<td>{{StringUtils.Join $.PackageDescriptor.Metadata.Provides ", "}}</td>
+			</tr>
+			{{end}}
+
+			{{if .PackageDescriptor.Metadata.Depends}}
+			<tr>
+				<td class="collapsing">
+					<h5>{{ctx.Locale.Tr "packages.arch.version.depends"}}</h5>
+				</td>
+				<td>{{StringUtils.Join $.PackageDescriptor.Metadata.Depends ", "}}</td>
+			</tr>
+			{{end}}
+
+			{{if .PackageDescriptor.Metadata.OptDepends}}
+			<tr>
+				<td class="collapsing">
+					<h5>{{ctx.Locale.Tr "packages.arch.version.optdepends"}}</h5>
+				</td>
+				<td>{{StringUtils.Join $.PackageDescriptor.Metadata.OptDepends ", "}}</td>
+			</tr>
+			{{end}}
+
+			{{if .PackageDescriptor.Metadata.MakeDepends}}
+			<tr>
+				<td class="collapsing">
+					<h5>{{ctx.Locale.Tr "packages.arch.version.makedepends"}}</h5>
+				</td>
+				<td>{{StringUtils.Join $.PackageDescriptor.Metadata.MakeDepends ", "}}</td>
+			</tr>
+			{{end}}
+
+			{{if .PackageDescriptor.Metadata.CheckDepends}}
+			<tr>
+				<td class="collapsing">
+					<h5>{{ctx.Locale.Tr "packages.arch.version.checkdepends"}}</h5>
+				</td>
+				<td>{{StringUtils.Join $.PackageDescriptor.Metadata.CheckDepends ", "}}</td>
+			</tr>
+			{{end}}
+
+			{{if .PackageDescriptor.Metadata.Conflicts}}
+			<tr>
+				<td class="collapsing">
+					<h5>{{ctx.Locale.Tr "packages.arch.version.conflicts"}}</h5>
+				</td>
+				<td>{{StringUtils.Join $.PackageDescriptor.Metadata.Conflicts ", "}}</td>
+			</tr>
+			{{end}}
+
+			{{if .PackageDescriptor.Metadata.Replaces}}
+			<tr>
+				<td class="collapsing">
+					<h5>{{ctx.Locale.Tr "packages.arch.version.replaces"}}</h5>
+				</td>
+				<td>{{StringUtils.Join $.PackageDescriptor.Metadata.Replaces ", "}}</td>
+			</tr>
+			{{end}}
+
+			{{if .PackageDescriptor.Metadata.Backup}}
+			<tr>
+				<td class="collapsing">
+					<h5>{{ctx.Locale.Tr "packages.arch.version.backup"}}</h5>
+				</td>
+				<td>{{StringUtils.Join $.PackageDescriptor.Metadata.Backup ", "}}</td>
+			</tr>
+			{{end}}
+		</tbody>
+	</table>
+</div>
+
+{{end}}
diff --git a/templates/package/metadata/arch.tmpl b/templates/package/metadata/arch.tmpl
new file mode 100644
index 0000000000..822973eb7d
--- /dev/null
+++ b/templates/package/metadata/arch.tmpl
@@ -0,0 +1,4 @@
+{{if eq .PackageDescriptor.Package.Type "arch"}}
+	{{range .PackageDescriptor.Metadata.License}}<div class="item" title="{{$.locale.Tr "packages.details.license"}}">{{svg "octicon-law" 16 "gt-mr-3"}} {{.}}</div>{{end}}
+	{{if .PackageDescriptor.Metadata.ProjectURL}}<div class="item">{{svg "octicon-link-external" 16 "mr-3"}} <a href="{{.PackageDescriptor.Metadata.ProjectURL}}" target="_blank" rel="noopener noreferrer me">{{ctx.Locale.Tr "packages.details.project_site"}}</a></div>{{end}}
+{{end}}
diff --git a/templates/package/view.tmpl b/templates/package/view.tmpl
index 1d87f4d3af..fe88e54317 100644
--- a/templates/package/view.tmpl
+++ b/templates/package/view.tmpl
@@ -19,6 +19,7 @@
 		<div class="issue-content">
 			<div class="issue-content-left">
 				{{template "package/content/alpine" .}}
+				{{template "package/content/arch" .}}
 				{{template "package/content/cargo" .}}
 				{{template "package/content/chef" .}}
 				{{template "package/content/composer" .}}
@@ -50,6 +51,7 @@
 					<div class="item">{{svg "octicon-calendar" 16 "tw-mr-2"}} {{TimeSinceUnix .PackageDescriptor.Version.CreatedUnix ctx.Locale}}</div>
 					<div class="item">{{svg "octicon-download" 16 "tw-mr-2"}} {{.PackageDescriptor.Version.DownloadCount}}</div>
 					{{template "package/metadata/alpine" .}}
+					{{template "package/metadata/arch" .}}
 					{{template "package/metadata/cargo" .}}
 					{{template "package/metadata/chef" .}}
 					{{template "package/metadata/composer" .}}
diff --git a/tests/integration/api_packages_arch_test.go b/tests/integration/api_packages_arch_test.go
new file mode 100644
index 0000000000..6062a88ea0
--- /dev/null
+++ b/tests/integration/api_packages_arch_test.go
@@ -0,0 +1,327 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+	"archive/tar"
+	"bufio"
+	"bytes"
+	"compress/gzip"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"testing"
+	"testing/fstest"
+
+	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/models/packages"
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+	arch_model "code.gitea.io/gitea/modules/packages/arch"
+	"code.gitea.io/gitea/tests"
+
+	"github.com/ProtonMail/go-crypto/openpgp/armor"
+	"github.com/ProtonMail/go-crypto/openpgp/packet"
+	"github.com/stretchr/testify/require"
+)
+
+func TestPackageArch(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+	user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+	unPack := func(s string) []byte {
+		data, _ := base64.StdEncoding.DecodeString(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(s), "\n", ""), "\r", ""))
+		return data
+	}
+	rootURL := fmt.Sprintf("/api/packages/%s/arch", user.Name)
+
+	pkgs := map[string][]byte{
+		"any": unPack(`
+KLUv/QBYXRMABmOHSbCWag6dY6d8VNtVR3rpBnWdBbkDAxM38Dj3XG3FK01TCKlWtMV9QpskYdsm
+e6fh5gWqM8edeurYNESoIUz/RmtyQy68HVrBj1p+AIoAYABFSJh4jcDyWNQgHIKIuNgIll64S4oY
+FFIUk6vJQBMIIl2iYtIysqKWVYMCYvXDpAKTMzVGwZTUWhbciFCglIMH1QMbEtjHpohSi8XRYwPr
+AwACSy/fzxO1FobizlP7sFgHcpx90Pus94Edjcc9GOustbD3PBprLUxH50IGC1sfw31c7LOfT4Qe
+nh0KP1uKywwdPrRYmuyIkWBHRlcLfeBIDpKKqw44N0K2nNAfFW5grHRfSShyVgaEIZwIVVmFGL7O
+88XDE5whJm4NkwA91dRoPBCcrgqozKSyah1QygsWkCshAaYrvbHCFdUTJCOgBpeUTMuJJ6+SRtcj
+wIRua8mGJyg7qWoqJQq9z/4+DU1rHrEO8f6QZ3HUu3IM7GY37u+jeWjUu45637yN+qj338cdi0Uc
+y0a9a+e5//1cYnPUu37dxr15khzNQ9/PE80aC/1okjz9mGo3bqP5Ue+scflGshdzx2g28061k2PW
+uKwzjmV/XzTzzmKdcfz3eRbJoRPddcaP/n4PSZqQeYa1PDtPQzOHJK0amfjvz0IUV/v38xHJK/rz
+JtFpalPD30drDWi7Bl8NB3J/P3csijQyldWZ8gy3TNslLsozMw74DhoAXoAfnE8xydUUHPZ3hML4
+2zVDGiEXSGYRx4BKQDcDJA5S9Ca25FRgPtSWSowZJpJTYAR9WCPHUDgACm6+hBecGDPNClpwHZ2A
+EQ==
+`),
+		"x86_64": unPack(`
+KLUv/QBYnRMAFmOJS7BUbg7Un8q21hxCopsOMn6UGTzJRbHI753uOeMdxZ+V7ajoETVxl9CSBCR5
+2a3K1vr1gwyp9gCTH422bRNxHEg7Z0z9HV4rH/DGFn8AjABjAFQ2oaUVMRRGViVoqmxAVKuoKQVM
+NJRwTDl9NcHCClliWjTpWin6sRUZsXSipWlAipQnleThRgFF5QTAzpth0UPFkhQeJRnYOaqSScEC
+djCPDwE8pQTfVXW9F7bmznX3YTNZDeP7IHgxDazNQhp+UDa798KeRgvvvbCamgsYdL461TfvcmlY
+djFowWYH5yaH5ztZcemh4omAkm7iQIWvGypNIXJQNgc7DVuHjx06I4MZGTIkeEBIOIL0OxcvnGps
+0TwxycqKYESrwwQYEDKI2F0hNXH1/PCQ2BS4Ykki48EAaflAbRHxYrRQbdAZ4oXVAMGCkYOXkBRb
+NkwjNCoIF07ByTlyfJhmoHQtCbFYDN+941783KqzusznmPePXJPluS1+cL/74Rd/1UHluW15blFv
+ol6e+8XPPZNDPN/Kc9vOdX/xNZrT8twWnH34U9Xkqw76rqqrPjPQl6nJde9i74e/8Mtz6zOjT3R7
+Uve8BrabpT4zanE83158MtVbkxbH84vPNWkGqeu2OF704vfRzAGl6mhRtXPdmOrRzFla+BO+DL34
+uHHN9r74usjkduX5VEhNz9TnxV9trSabvYAwuIZffN0zSeZM3c3GUHX8dG6jeUgHGgBbgB9cUDHJ
+1RR09teBwvjbNUMaIRdIZhHHgEpANwMkDpL0JsbkVFA+0JZKjBkmklNgBH1YI8dQOAAKbr6EF5wY
+M80KWnAdnYAR
+`),
+		"aarch64": unPack(`
+KLUv/QBYdRQAVuSMS7BUbg7Un8q21hxCopsOMn6UGTzJRbHI753uOeMdxZ+V7ajoEbUkUXbXhXW/
+7FanWzv7B/EcMxhodFqyZkUcB9LOGVN/h9MqG7zFFmoAaQB8AEFrvpXntn3V/cXXaE7Lc9uP5uFP
+VXPl+ue7qnJ9Zp8vU3PVvYu9HvbAL8+tz4y+0O1J3TPXqbZ5l3+lapk5ee+L577qXvdf+Atn+P69
+4Qz8QhpYw4/xd78Q3/v6Wg28974u1Ojc2ODseAGpHs2crYG4kef84uNGnu198fWQuVq+8ymQmp5p
+z4vPbRjOaBC+FxziF1/3TJI5U3ezMlQdPZ3baA7SMhnMunvHvfg5rrO6zOeY94+rJstzW/zgetfD
+Lz7XP+W5bXluUW+hXp77xc89kwFRTF1PrKxAFpgXT7ZWhjzYjpRIStGyNCAGBYM6AnGrkKKCAmAH
+k3HBI8VyBBYdGdApmoqJYQE62EeIADCkBF1VOW0WYnz/+y6ufTMaDQ2GDDme7Wapz4xa3JpvLz6Z
+6q1Ji1vzi79q0vxR+ba4dejF76OZ80nV0aJqX3VjKCsuP1g0EWDSURyw0JVDZWlEzsnmYLdh8wDS
+I2dkIEMjxsSOiAlJjH4HIwbTjayZJidXVxKQYH2gICOCBhK7KqMlLZ4gMCU1BapYlsTAXnywepyy
+jMBmtEhxyCnCZdUAwYKxAxeRFVk4TCL0aYgWjt3kHTg9SjVStppI2YCSWshUEFGdmJmyCVGpnqIU
+KNlA0hEjIOACGSLqYpXAD5SSNVT2MJRJwREAF4FRHPBlCJMSNwFguGAWDJBg+KIArkIJGNtCydUL
+TuN1oBh/+zKkEblAsgjGqVgUwKLP+UOMOGCpAhICtg6ncFJH`),
+		"other": unPack(`
+KLUv/QBYbRMABuOHS9BSNQdQ56F+xNFoV3CijY54JYt3VqV1iUU3xmj00y2pyBOCuokbhDYpvNsj
+ZJeCxqH+nQFpMf4Wa92okaZoF4eH6HsXXCBo+qy3Fn4AigBgAEaYrLCQEuAom6YbHyuKZAFYksqi
+sSOFiRs0WDmlACk0CnpnaAeKiCS3BlwVkViJEbDS43lFNbLkZEmGhc305Nn4AMLGiUkBDiMTG5Vz
+q4ZISjCofEfR1NpXijvP2X95Hu1e+zLalc0+mjeT3Z/FPGvt62WymbX2dXMDIYKDLjjP8n03RrPf
+A1vOApwGOh2MgE2LpgZrgXLDF2CUJ15idG2J8GCSgcc2ZVRgA8+RHD0k2VJjg6mRUgGGhBWEyEcz
+5EePLhUeWlYhoFCKONxUiBiIUiQeDIqiQwkjLiyqnF5eGs6a2gGRapbU9JRyuXAlPemYajlJojJd
+GBBJjo5GxFRkITOAvLhSCr2TDz4uzdU8Yh3i/SHP4qh3vTG2s9198NP8M+pdR73BvIP6qPeDjzsW
+gTi+jXrXWOe5P/jZxOeod/287v6JljzNP99RNM0a+/x4ljz3LNV2t5v9qHfW2Pyg24u54zSfObWX
+Y9bYrCTHtwdfPPPOYiU5fvB5FssfNN2V5EIPfg9LnM+JhtVEO8+FZw5LXA068YNPhimu9sHPQiWv
+qc6fE9BTnxIe/LTKatab+WYu7T74uWNRxJW5W5Ux0bDLuG1ioCwjg4DvGgBcgB8cUDHJ1RQ89neE
+wvjbNUMiIZdo5hbHgEpANwMkDnL0Jr7kVFg+0pZKjBkmklNgBH1YI8dQOAAKbr6EF5wYM80KWnAd
+nYAR`),
+	}
+
+	t.Run("RepositoryKey", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		req := NewRequest(t, "GET", rootURL+"/repository.key")
+		resp := MakeRequest(t, req, http.StatusOK)
+
+		require.Equal(t, "application/pgp-keys", resp.Header().Get("Content-Type"))
+		require.Contains(t, resp.Body.String(), "-----BEGIN PGP PUBLIC KEY BLOCK-----")
+	})
+
+	t.Run("Upload", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		req := NewRequestWithBody(t, "PUT", rootURL+"/default", bytes.NewReader(pkgs["any"]))
+		MakeRequest(t, req, http.StatusUnauthorized)
+
+		req = NewRequestWithBody(t, "PUT", rootURL+"/default", bytes.NewReader(pkgs["any"])).
+			AddBasicAuth(user.Name)
+		MakeRequest(t, req, http.StatusCreated)
+
+		pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeArch)
+		require.NoError(t, err)
+		require.Len(t, pvs, 1)
+
+		pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+		require.NoError(t, err)
+		require.Nil(t, pd.SemVer)
+		require.IsType(t, &arch_model.VersionMetadata{}, pd.Metadata)
+		require.Equal(t, "test", pd.Package.Name)
+		require.Equal(t, "1.0.0-1", pd.Version.Version)
+
+		pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+		require.NoError(t, err)
+		require.Len(t, pfs, 2) // zst and zst.sig
+		require.True(t, pfs[0].IsLead)
+
+		pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID)
+		require.NoError(t, err)
+		require.Equal(t, int64(len(pkgs["any"])), pb.Size)
+
+		req = NewRequestWithBody(t, "PUT", rootURL+"/default", bytes.NewReader(pkgs["any"])).
+			AddBasicAuth(user.Name)
+		MakeRequest(t, req, http.StatusConflict)
+		req = NewRequestWithBody(t, "PUT", rootURL+"/default", bytes.NewReader(pkgs["x86_64"])).
+			AddBasicAuth(user.Name)
+		MakeRequest(t, req, http.StatusCreated)
+		req = NewRequestWithBody(t, "PUT", rootURL+"/other", bytes.NewReader(pkgs["any"])).
+			AddBasicAuth(user.Name)
+		MakeRequest(t, req, http.StatusCreated)
+		req = NewRequestWithBody(t, "PUT", rootURL+"/other", bytes.NewReader(pkgs["aarch64"])).
+			AddBasicAuth(user.Name)
+		MakeRequest(t, req, http.StatusCreated)
+
+		req = NewRequestWithBody(t, "PUT", rootURL+"/base", bytes.NewReader(pkgs["other"])).
+			AddBasicAuth(user.Name)
+		MakeRequest(t, req, http.StatusCreated)
+		req = NewRequestWithBody(t, "PUT", rootURL+"/base", bytes.NewReader(pkgs["x86_64"])).
+			AddBasicAuth(user.Name)
+		MakeRequest(t, req, http.StatusCreated)
+		req = NewRequestWithBody(t, "PUT", rootURL+"/base", bytes.NewReader(pkgs["aarch64"])).
+			AddBasicAuth(user.Name)
+		MakeRequest(t, req, http.StatusCreated)
+	})
+
+	t.Run("Download", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+		req := NewRequest(t, "GET", rootURL+"/default/x86_64/test-1.0.0-1-x86_64.pkg.tar.zst")
+		resp := MakeRequest(t, req, http.StatusOK)
+		require.Equal(t, pkgs["x86_64"], resp.Body.Bytes())
+
+		req = NewRequest(t, "GET", rootURL+"/default/x86_64/test-1.0.0-1-any.pkg.tar.zst")
+		resp = MakeRequest(t, req, http.StatusOK)
+		require.Equal(t, pkgs["any"], resp.Body.Bytes())
+
+		req = NewRequest(t, "GET", rootURL+"/default/x86_64/test-1.0.0-1-aarch64.pkg.tar.zst")
+		MakeRequest(t, req, http.StatusNotFound)
+
+		req = NewRequest(t, "GET", rootURL+"/other/x86_64/test-1.0.0-1-x86_64.pkg.tar.zst")
+		MakeRequest(t, req, http.StatusNotFound)
+
+		req = NewRequest(t, "GET", rootURL+"/other/x86_64/test-1.0.0-1-any.pkg.tar.zst")
+		resp = MakeRequest(t, req, http.StatusOK)
+		require.Equal(t, pkgs["any"], resp.Body.Bytes())
+	})
+
+	t.Run("SignVerify", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+		req := NewRequest(t, "GET", rootURL+"/repository.key")
+		respPub := MakeRequest(t, req, http.StatusOK)
+
+		req = NewRequest(t, "GET", rootURL+"/other/x86_64/test-1.0.0-1-any.pkg.tar.zst")
+		respPkg := MakeRequest(t, req, http.StatusOK)
+
+		req = NewRequest(t, "GET", rootURL+"/other/x86_64/test-1.0.0-1-any.pkg.tar.zst.sig")
+		respSig := MakeRequest(t, req, http.StatusOK)
+
+		if err := gpgVerify(respPub.Body.Bytes(), respSig.Body.Bytes(), respPkg.Body.Bytes()); err != nil {
+			t.Fatal(err)
+		}
+	})
+
+	t.Run("Repository", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+		req := NewRequest(t, "GET", rootURL+"/repository.key")
+		respPub := MakeRequest(t, req, http.StatusOK)
+
+		req = NewRequest(t, "GET", rootURL+"/base/x86_64/base.db")
+		respPkg := MakeRequest(t, req, http.StatusOK)
+
+		req = NewRequest(t, "GET", rootURL+"/base/x86_64/base.db.sig")
+		respSig := MakeRequest(t, req, http.StatusOK)
+
+		if err := gpgVerify(respPub.Body.Bytes(), respSig.Body.Bytes(), respPkg.Body.Bytes()); err != nil {
+			t.Fatal(err)
+		}
+		files, err := listGzipFiles(respPkg.Body.Bytes())
+		require.NoError(t, err)
+		require.Len(t, files, 2)
+		for s, d := range files {
+			name := getProperty(string(d.Data), "NAME")
+			ver := getProperty(string(d.Data), "VERSION")
+			require.Equal(t, name+"-"+ver+"/desc", s)
+			fn := getProperty(string(d.Data), "FILENAME")
+			pgp := getProperty(string(d.Data), "PGPSIG")
+			req = NewRequest(t, "GET", rootURL+"/base/x86_64/"+fn+".sig")
+			respSig := MakeRequest(t, req, http.StatusOK)
+			decodeString, err := base64.StdEncoding.DecodeString(pgp)
+			require.NoError(t, err)
+			require.Equal(t, respSig.Body.Bytes(), decodeString)
+		}
+	})
+	t.Run("Delete", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+		req := NewRequestWithBody(t, "DELETE", rootURL+"/base/notfound/1.0.0-1", nil).
+			AddBasicAuth(user.Name)
+		MakeRequest(t, req, http.StatusNotFound)
+
+		req = NewRequestWithBody(t, "DELETE", rootURL+"/base/test/1.0.0-1", nil).
+			AddBasicAuth(user.Name)
+		MakeRequest(t, req, http.StatusNoContent)
+
+		req = NewRequest(t, "GET", rootURL+"/base/x86_64/base.db")
+		respPkg := MakeRequest(t, req, http.StatusOK)
+		files, err := listGzipFiles(respPkg.Body.Bytes())
+		require.NoError(t, err)
+		require.Len(t, files, 1)
+
+		req = NewRequestWithBody(t, "DELETE", rootURL+"/base/test2/1.0.0-1", nil).
+			AddBasicAuth(user.Name)
+		MakeRequest(t, req, http.StatusNoContent)
+		req = NewRequest(t, "GET", rootURL+"/base/x86_64/base.db")
+		MakeRequest(t, req, http.StatusNotFound)
+
+		req = NewRequest(t, "GET", rootURL+"/default/x86_64/base.db")
+		respPkg = MakeRequest(t, req, http.StatusOK)
+		files, err = listGzipFiles(respPkg.Body.Bytes())
+		require.NoError(t, err)
+		require.Len(t, files, 1)
+	})
+}
+
+func getProperty(data, key string) string {
+	r := bufio.NewReader(strings.NewReader(data))
+	for {
+		line, _, err := r.ReadLine()
+		if err != nil {
+			return ""
+		}
+		if strings.Contains(string(line), "%"+key+"%") {
+			readLine, _, _ := r.ReadLine()
+			return string(readLine)
+		}
+	}
+}
+
+func listGzipFiles(data []byte) (fstest.MapFS, error) {
+	reader, err := gzip.NewReader(bytes.NewBuffer(data))
+	defer reader.Close()
+	if err != nil {
+		return nil, err
+	}
+	tarRead := tar.NewReader(reader)
+	files := make(fstest.MapFS)
+	for {
+		cur, err := tarRead.Next()
+		if err == io.EOF {
+			break
+		} else if err != nil {
+			return nil, err
+		}
+		if cur.Typeflag != tar.TypeReg {
+			continue
+		}
+		data, err := io.ReadAll(tarRead)
+		if err != nil {
+			return nil, err
+		}
+		files[cur.Name] = &fstest.MapFile{Data: data}
+	}
+	return files, nil
+}
+
+func gpgVerify(pub, sig, data []byte) error {
+	sigPack, err := packet.Read(bytes.NewBuffer(sig))
+	if err != nil {
+		return err
+	}
+	signature, ok := sigPack.(*packet.Signature)
+	if !ok {
+		return errors.New("invalid sign key")
+	}
+	pubBlock, err := armor.Decode(bytes.NewReader(pub))
+	if err != nil {
+		return err
+	}
+	pack, err := packet.Read(pubBlock.Body)
+	if err != nil {
+		return err
+	}
+	publicKey, ok := pack.(*packet.PublicKey)
+	if !ok {
+		return errors.New("invalid public key")
+	}
+	hash := signature.Hash.New()
+	_, err = hash.Write(data)
+	if err != nil {
+		return err
+	}
+	return publicKey.VerifySignature(hash, signature)
+}
diff --git a/web_src/svg/gitea-arch.svg b/web_src/svg/gitea-arch.svg
new file mode 100644
index 0000000000..ba8254d804
--- /dev/null
+++ b/web_src/svg/gitea-arch.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#1793d1" d="M256 72c-14 35-23 57-39 91 10 11 22 23 41 36-21-8-35-17-45-26-21 43-53 103-117 220 50-30 90-48 127-55-2-7-3-14-3-22v-1c1-33 18-58 38-56 20 1 36 29 35 62l-2 17c36 7 75 26 125 54l-27-50c-13-10-27-23-55-38 19 5 33 11 44 17-86-159-93-180-122-250z"/></svg>
\ No newline at end of file