diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7ad53b3..330b2f9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -64,7 +64,7 @@ jobs: acceptance-tests: strategy: matrix: - pkgFormat: [ deb, rpm, apk ] + pkgFormat: [ deb, rpm, apk, archlinux ] pkgPlatform: [ amd64, arm64, 386, ppc64le, armv6, armv7, s390x ] runs-on: ubuntu-latest env: @@ -88,4 +88,3 @@ jobs: run: ./scripts/test.sh acceptance ubuntu-latest env: TEST_PATTERN: "/${{ matrix.pkgFormat }}/${{ matrix.pkgPlatform }}/" - diff --git a/acceptance_test.go b/acceptance_test.go index 9201259..e2dd7d0 100644 --- a/acceptance_test.go +++ b/acceptance_test.go @@ -13,6 +13,7 @@ import ( "github.com/goreleaser/nfpm/v2" _ "github.com/goreleaser/nfpm/v2/apk" + _ "github.com/goreleaser/nfpm/v2/arch" _ "github.com/goreleaser/nfpm/v2/deb" _ "github.com/goreleaser/nfpm/v2/rpm" "github.com/stretchr/testify/require" @@ -20,9 +21,10 @@ import ( // nolint: gochecknoglobals var formatArchs = map[string][]string{ - "apk": {"amd64", "arm64", "386", "ppc64le", "armv6", "armv7", "s390x"}, - "deb": {"amd64", "arm64", "ppc64le", "armv7", "s390x"}, - "rpm": {"amd64", "arm64", "ppc64le", "armv7"}, + "apk": {"amd64", "arm64", "386", "ppc64le", "armv6", "armv7", "s390x"}, + "deb": {"amd64", "arm64", "ppc64le", "armv7", "s390x"}, + "rpm": {"amd64", "arm64", "ppc64le", "armv7"}, + "archlinux": {"amd64"}, } func TestCore(t *testing.T) { diff --git a/apk/apk.go b/apk/apk.go index 7715a46..c280cfe 100644 --- a/apk/apk.go +++ b/apk/apk.go @@ -101,6 +101,11 @@ func (a *Apk) ConventionalFileName(info *nfpm.Info) string { return fmt.Sprintf("%s_%s_%s.apk", info.Name, version, info.Arch) } +// ConventionalExtension returns the file name conventionally used for Apk packages +func (*Apk) ConventionalExtension() string { + return ".apk" +} + // Package writes a new apk package to the given writer using the given info. func (*Apk) Package(info *nfpm.Info, apk io.Writer) (err error) { info = ensureValidArch(info) diff --git a/arch/arch.go b/arch/arch.go new file mode 100644 index 0000000..e724fc1 --- /dev/null +++ b/arch/arch.go @@ -0,0 +1,693 @@ +// Package arch implements nfpm.Packager providing bindings for Arch Linux packages. +package arch + +import ( + "archive/tar" + "bytes" + "crypto/md5" + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/goreleaser/nfpm/v2" + "github.com/goreleaser/nfpm/v2/files" + "github.com/klauspost/compress/zstd" + "github.com/klauspost/pgzip" +) + +var ErrInvalidPkgName = errors.New("archlinux: package names may only contain alphanumeric characters or one of ., _, +, or -, and may not start with hyphen or dot") + +const packagerName = "archlinux" + +// nolint: gochecknoinits +func init() { + nfpm.RegisterPackager(packagerName, Default) +} + +// Default ArchLinux packager. +// nolint: gochecknoglobals +var Default = ArchLinux{} + +type ArchLinux struct{} + +// nolint: gochecknoglobals +var archToArchLinux = map[string]string{ + "all": "any", + "amd64": "x86_64", + "386": "i686", + "arm64": "aarch64", + "arm7": "armv7h", + "arm6": "armv6h", + "arm5": "arm", +} + +func ensureValidArch(info *nfpm.Info) *nfpm.Info { + if info.ArchLinux.Arch != "" { + info.Arch = info.ArchLinux.Arch + } else if arch, ok := archToArchLinux[info.Arch]; ok { + info.Arch = arch + } + + return info +} + +// ConventionalFileName returns a file name for a package conforming +// to Arch Linux package naming guidelines. See: +// https://wiki.archlinux.org/title/Arch_package_guidelines#Package_naming +func (ArchLinux) ConventionalFileName(info *nfpm.Info) string { + info = ensureValidArch(info) + + pkgrel, err := strconv.Atoi(info.Release) + if err != nil { + pkgrel = 1 + } + + name := fmt.Sprintf( + "%s-%s-%d-%s.pkg.tar.zst", + info.Name, + info.Version, + pkgrel, + info.Arch, + ) + + return validPkgName(name) +} + +// validPkgName removes any invalid characters from a string +func validPkgName(s string) string { + s = strings.Map(mapValidChar, s) + s = strings.TrimLeft(s, "-.") + return s +} + +// nameIsValid checks whether a package name is valid +func nameIsValid(s string) bool { + return s != "" && s == validPkgName(s) +} + +// mapValidChar returns r if it is allowed, otherwise, returns -1 +func mapValidChar(r rune) rune { + if r >= 'a' && r <= 'z' || + r >= 'A' && r <= 'Z' || + r >= '0' && r <= '9' || + isOneOf(r, '.', '_', '+', '-') { + return r + } + return -1 +} + +// isOneOf checks whether a rune is one of the runes in rr +func isOneOf(r rune, rr ...rune) bool { + for _, char := range rr { + if r == char { + return true + } + } + return false +} + +// Package writes a new archlinux package to the given writer using the given info. +func (ArchLinux) Package(info *nfpm.Info, w io.Writer) error { + if !nameIsValid(info.Name) { + return ErrInvalidPkgName + } + + zw, err := zstd.NewWriter(w) + if err != nil { + return err + } + defer zw.Close() + + tw := tar.NewWriter(zw) + defer tw.Close() + + entries, totalSize, err := createFilesInTar(info, tw) + if err != nil { + return err + } + + pkginfoEntry, err := createPkginfo(info, tw, totalSize) + if err != nil { + return err + } + + // .PKGINFO must be the first entry in .MTREE + entries = append([]MtreeEntry{*pkginfoEntry}, entries...) + + err = createMtree(info, tw, entries) + if err != nil { + return err + } + + return createScripts(info, tw) +} + +// ConventionalExtension returns the file name conventionally used for Arch Linux packages +func (ArchLinux) ConventionalExtension() string { + return ".pkg.tar.zst" +} + +// createFilesInTar adds the files described in the given info to the given tar writer +func createFilesInTar(info *nfpm.Info, tw *tar.Writer) ([]MtreeEntry, int64, error) { + created := map[string]struct{}{} + var entries []MtreeEntry + var totalSize int64 + + for _, content := range info.Contents { + if content.Packager != "" && content.Packager != packagerName { + continue + } + + path := normalizePath(content.Destination) + + switch content.Type { + case "ghost": + // Ignore ghost files + case "dir": + err := createDirs(content.Destination, tw, created) + if err != nil { + return nil, 0, err + } + + modtime := time.Now() + // If the time is given, use it + if content.FileInfo != nil && !content.ModTime().IsZero() { + modtime = content.ModTime() + } + + entries = append(entries, MtreeEntry{ + Destination: path, + Time: modtime.Unix(), + Type: content.Type, + }) + case "symlink": + dir := filepath.Dir(path) + err := createDirs(dir, tw, created) + if err != nil { + return nil, 0, err + } + + modtime := time.Now() + // If the time is given, use it + if content.FileInfo != nil && !content.ModTime().IsZero() { + modtime = content.ModTime() + } + + err = tw.WriteHeader(&tar.Header{ + Name: normalizePath(content.Destination), + Linkname: content.Source, + ModTime: modtime, + Typeflag: tar.TypeSymlink, + }) + if err != nil { + return nil, 0, err + } + + entries = append(entries, MtreeEntry{ + LinkSource: content.Source, + Destination: path, + Time: modtime.Unix(), + Mode: 0o777, + Type: content.Type, + }) + default: + dir := filepath.Dir(path) + err := createDirs(dir, tw, created) + if err != nil { + return nil, 0, err + } + + src, err := os.Open(content.Source) + if err != nil { + return nil, 0, err + } + + srcFi, err := src.Stat() + if err != nil { + return nil, 0, err + } + + header := &tar.Header{ + Name: path, + Mode: int64(srcFi.Mode()), + Typeflag: tar.TypeReg, + Size: srcFi.Size(), + ModTime: srcFi.ModTime(), + } + + if content.FileInfo != nil && content.Mode() != 0 { + header.Mode = int64(content.Mode()) + } + + if content.FileInfo != nil && !content.ModTime().IsZero() { + header.ModTime = content.ModTime() + } + + if content.FileInfo != nil && content.Size() != 0 { + header.Size = content.Size() + } + + err = tw.WriteHeader(header) + if err != nil { + return nil, 0, err + } + + sha256Hash := sha256.New() + md5Hash := md5.New() + + w := io.MultiWriter(tw, sha256Hash, md5Hash) + + _, err = io.Copy(w, src) + if err != nil { + return nil, 0, err + } + + entries = append(entries, MtreeEntry{ + Destination: path, + Time: srcFi.ModTime().Unix(), + Mode: int64(srcFi.Mode()), + Size: srcFi.Size(), + Type: content.Type, + MD5: md5Hash.Sum(nil), + SHA256: sha256Hash.Sum(nil), + }) + + totalSize += srcFi.Size() + } + } + + return entries, totalSize, nil +} + +func createDirs(dst string, tw *tar.Writer, created map[string]struct{}) error { + for _, path := range neededPaths(dst) { + path = normalizePath(path) + "/" + + if _, ok := created[path]; ok { + continue + } + + err := tw.WriteHeader(&tar.Header{ + Name: path, + Mode: 0o755, + Typeflag: tar.TypeDir, + ModTime: time.Now(), + Uname: "root", + Gname: "root", + }) + if err != nil { + return fmt.Errorf("failed to create folder: %w", err) + } + + created[path] = struct{}{} + } + + return nil +} + +func defaultStr(s, def string) string { + if s == "" { + return def + } + return s +} + +func neededPaths(dst string) []string { + dst = files.ToNixPath(dst) + split := strings.Split(strings.Trim(dst, "/."), "/") + + var sb strings.Builder + var paths []string + for index, elem := range split { + if index != 0 { + sb.WriteRune('/') + } + sb.WriteString(elem) + paths = append(paths, sb.String()) + } + + return paths +} + +func normalizePath(src string) string { + return files.ToNixPath(strings.TrimPrefix(src, "/")) +} + +func createPkginfo(info *nfpm.Info, tw *tar.Writer, totalSize int64) (*MtreeEntry, error) { + if !nameIsValid(info.Name) { + return nil, ErrInvalidPkgName + } + + buf := &bytes.Buffer{} + + info = ensureValidArch(info) + + pkgrel, err := strconv.Atoi(info.Release) + if err != nil { + pkgrel = 1 + } + + pkgver := fmt.Sprintf("%s-%d", info.Version, pkgrel) + if info.Epoch != "" { + epoch, err := strconv.ParseUint(info.Epoch, 10, 64) + if err == nil { + pkgver = fmt.Sprintf("%d:%s-%d", epoch, info.Version, pkgrel) + } + } + + // Description cannot contain newlines + pkgdesc := strings.ReplaceAll(info.Description, "\n", " ") + + _, err = io.WriteString(buf, "# Generated by nfpm\n") + if err != nil { + return nil, err + } + + builddate := strconv.FormatInt(time.Now().Unix(), 10) + totalSizeStr := strconv.FormatInt(totalSize, 10) + + err = writeKVPairs(buf, map[string]string{ + "size": totalSizeStr, + "pkgname": info.Name, + "pkgbase": defaultStr(info.ArchLinux.Pkgbase, info.Name), + "pkgver": pkgver, + "pkgdesc": pkgdesc, + "url": info.Homepage, + "builddate": builddate, + "packager": defaultStr(info.ArchLinux.Packager, "Unknown Packager"), + "arch": info.Arch, + "license": info.License, + }) + if err != nil { + return nil, err + } + + for _, replaces := range info.Replaces { + err = writeKVPair(buf, "replaces", replaces) + if err != nil { + return nil, err + } + } + + for _, conflict := range info.Conflicts { + err = writeKVPair(buf, "conflict", conflict) + if err != nil { + return nil, err + } + } + + for _, provides := range info.Provides { + err = writeKVPair(buf, "provides", provides) + if err != nil { + return nil, err + } + } + + for _, depend := range info.Depends { + err = writeKVPair(buf, "depend", depend) + if err != nil { + return nil, err + } + } + + for _, content := range info.Contents { + if content.Type == "config" || content.Type == "config|noreplace" { + path := normalizePath(content.Destination) + path = strings.TrimPrefix(path, "./") + + err = writeKVPair(buf, "backup", path) + if err != nil { + return nil, err + } + } + } + + size := buf.Len() + + err = tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, + Mode: 0o644, + Name: ".PKGINFO", + Size: int64(size), + ModTime: time.Now(), + }) + if err != nil { + return nil, err + } + + md5Hash := md5.New() + sha256Hash := sha256.New() + + r := io.TeeReader(buf, md5Hash) + r = io.TeeReader(r, sha256Hash) + + _, err = io.Copy(tw, r) + if err != nil { + return nil, err + } + + return &MtreeEntry{ + Destination: ".PKGINFO", + Time: time.Now().Unix(), + Mode: 0o644, + Size: int64(size), + Type: "file", + MD5: md5Hash.Sum(nil), + SHA256: sha256Hash.Sum(nil), + }, nil +} + +func writeKVPairs(w io.Writer, s map[string]string) error { + for key, val := range s { + err := writeKVPair(w, key, val) + if err != nil { + return err + } + } + return nil +} + +func writeKVPair(w io.Writer, key, value string) error { + if value == "" { + return nil + } + + _, err := io.WriteString(w, key) + if err != nil { + return err + } + + _, err = io.WriteString(w, " = ") + if err != nil { + return err + } + + _, err = io.WriteString(w, value) + if err != nil { + return err + } + + _, err = io.WriteString(w, "\n") + return err +} + +type MtreeEntry struct { + LinkSource string + Destination string + Time int64 + Mode int64 + Size int64 + Type string + MD5 []byte + SHA256 []byte +} + +func (me *MtreeEntry) WriteTo(w io.Writer) (int64, error) { + switch me.Type { + case "dir": + n, err := fmt.Fprintf( + w, + "./%s time=%d.0 type=dir\n", + normalizePath(me.Destination), + me.Time, + ) + return int64(n), err + case "symlink": + n, err := fmt.Fprintf( + w, + "./%s time=%d.0 mode=%o type=link link=%s\n", + normalizePath(me.Destination), + me.Time, + me.Mode, + me.LinkSource, + ) + return int64(n), err + default: + n, err := fmt.Fprintf( + w, + "./%s time=%d.0 mode=%o size=%d type=file md5digest=%x sha256digest=%x\n", + normalizePath(me.Destination), + me.Time, + me.Mode, + me.Size, + me.MD5, + me.SHA256, + ) + return int64(n), err + } +} + +func createMtree(info *nfpm.Info, tw *tar.Writer, entries []MtreeEntry) error { + buf := &bytes.Buffer{} + gw := pgzip.NewWriter(buf) + defer gw.Close() + + created := map[string]struct{}{} + + _, err := io.WriteString(gw, "#mtree\n") + if err != nil { + return err + } + + for _, entry := range entries { + destDir := filepath.Dir(entry.Destination) + + dirs := createDirsMtree(destDir, created) + for _, dir := range dirs { + _, err = dir.WriteTo(gw) + if err != nil { + return err + } + } + + _, err = entry.WriteTo(gw) + if err != nil { + return err + } + } + + gw.Close() + + err = tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, + Mode: 0o644, + Name: ".MTREE", + Size: int64(buf.Len()), + ModTime: time.Now(), + }) + if err != nil { + return err + } + + _, err = io.Copy(tw, buf) + return err +} + +func createDirsMtree(dst string, created map[string]struct{}) []MtreeEntry { + var out []MtreeEntry + for _, path := range neededPaths(dst) { + path = normalizePath(path) + "/" + + if path == "./" { + continue + } + + if _, ok := created[path]; ok { + continue + } + + out = append(out, MtreeEntry{ + Destination: path, + Time: time.Now().Unix(), + Mode: 0o755, + Type: "dir", + }) + + created[path] = struct{}{} + } + return out +} + +func createScripts(info *nfpm.Info, tw *tar.Writer) error { + scripts := map[string]string{} + + if info.Scripts.PreInstall != "" { + scripts["pre_install"] = info.Scripts.PreInstall + } + + if info.Scripts.PostInstall != "" { + scripts["post_install"] = info.Scripts.PostInstall + } + + if info.Scripts.PreRemove != "" { + scripts["pre_remove"] = info.Scripts.PreRemove + } + + if info.Scripts.PostRemove != "" { + scripts["post_remove"] = info.Scripts.PostRemove + } + + if info.ArchLinux.Scripts.PreUpgrade != "" { + scripts["pre_upgrade"] = info.ArchLinux.Scripts.PreUpgrade + } + + if info.ArchLinux.Scripts.PostUpgrade != "" { + scripts["post_upgrade"] = info.ArchLinux.Scripts.PostUpgrade + } + + if len(scripts) == 0 { + return nil + } + + buf := &bytes.Buffer{} + + err := writeScripts(buf, scripts) + if err != nil { + return err + } + + err = tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, + Mode: 0o644, + Name: ".INSTALL", + Size: int64(buf.Len()), + ModTime: time.Now(), + }) + if err != nil { + return err + } + + _, err = io.Copy(tw, buf) + return err +} + +func writeScripts(w io.Writer, scripts map[string]string) error { + for script, path := range scripts { + fmt.Fprintf(w, "function %s() {\n", script) + + fl, err := os.Open(path) + if err != nil { + return err + } + + _, err = io.Copy(w, fl) + if err != nil { + return err + } + + fl.Close() + + _, err = io.WriteString(w, "\n}\n\n") + if err != nil { + return err + } + } + + return nil +} diff --git a/arch/arch_test.go b/arch/arch_test.go new file mode 100644 index 0000000..c51d0d8 --- /dev/null +++ b/arch/arch_test.go @@ -0,0 +1,280 @@ +package arch + +import ( + "archive/tar" + "bytes" + "io" + "strings" + "testing" + + "github.com/goreleaser/nfpm/v2" + "github.com/goreleaser/nfpm/v2/files" + "github.com/klauspost/pgzip" + "github.com/stretchr/testify/require" +) + +func exampleInfo() *nfpm.Info { + return nfpm.WithDefaults(&nfpm.Info{ + Name: "foo-test", + Arch: "amd64", + Description: "Foo does things", + Priority: "extra", + Maintainer: "Carlos A Becker ", + Version: "1.0.0", + Section: "default", + Homepage: "http://carlosbecker.com", + Vendor: "nope", + License: "MIT", + Overridables: nfpm.Overridables{ + Depends: []string{ + "bash", + }, + Replaces: []string{ + "svn", + }, + Provides: []string{ + "bzr", + }, + Conflicts: []string{ + "zsh", + }, + Contents: []*files.Content{ + { + Source: "../testdata/fake", + Destination: "/usr/local/bin/fake", + }, + { + Source: "../testdata/whatever.conf", + Destination: "/etc/fake/fake.conf", + Type: "config", + }, + { + Destination: "/var/log/whatever", + Type: "dir", + }, + { + Destination: "/usr/share/whatever", + Type: "dir", + }, + { + Source: "/etc/fake/fake.conf", + Destination: "/etc/fake/fake-link.conf", + Type: "symlink", + }, + }, + Scripts: nfpm.Scripts{ + PreInstall: "../testdata/scripts/preinstall.sh", + PostInstall: "../testdata/scripts/postinstall.sh", + PreRemove: "../testdata/scripts/preremove.sh", + PostRemove: "../testdata/scripts/postremove.sh", + }, + ArchLinux: nfpm.ArchLinux{ + Scripts: nfpm.ArchLinuxScripts{ + PreUpgrade: "../testdata/scripts/preupgrade.sh", + PostUpgrade: "../testdata/scripts/postupgrade.sh", + }, + }, + }, + }) +} + +func TestArch(t *testing.T) { + for _, arch := range []string{"386", "amd64", "arm64"} { + arch := arch + t.Run(arch, func(t *testing.T) { + info := exampleInfo() + info.Arch = arch + err := Default.Package(info, io.Discard) + require.NoError(t, err) + }) + } +} + +func TestArchNoFiles(t *testing.T) { + info := exampleInfo() + info.Contents = nil + info.Scripts = nfpm.Scripts{} + info.ArchLinux = nfpm.ArchLinux{} + err := Default.Package(info, io.Discard) + require.NoError(t, err) +} + +func TestArchNoInfo(t *testing.T) { + err := Default.Package(nfpm.WithDefaults(&nfpm.Info{}), io.Discard) + require.Error(t, err) +} + +func TestArchConventionalFileName(t *testing.T) { + for _, arch := range []string{"386", "amd64", "arm64"} { + arch := arch + t.Run(arch, func(t *testing.T) { + info := exampleInfo() + info.Arch = arch + name := Default.ConventionalFileName(info) + require.Equal(t, + "foo-test-1.0.0-1-"+archToArchLinux[arch]+".pkg.tar.zst", + name, + ) + }) + } +} + +func TestArchPkginfo(t *testing.T) { + pkginfoData, err := makeTestPkginfo(t, exampleInfo()) + require.NoError(t, err) + fields := extractPkginfoFields(pkginfoData) + require.Equal(t, "foo-test", fields["pkgname"]) + require.Equal(t, "foo-test", fields["pkgbase"]) + require.Equal(t, "1.0.0-1", fields["pkgver"]) + require.Equal(t, "Foo does things", fields["pkgdesc"]) + require.Equal(t, "http://carlosbecker.com", fields["url"]) + require.Equal(t, "Unknown Packager", fields["packager"]) + require.Equal(t, "x86_64", fields["arch"]) + require.Equal(t, "MIT", fields["license"]) + require.Equal(t, "1234", fields["size"]) + require.Equal(t, "svn", fields["replaces"]) + require.Equal(t, "zsh", fields["conflict"]) + require.Equal(t, "bzr", fields["provides"]) + require.Equal(t, "bash", fields["depend"]) + require.Equal(t, "etc/fake/fake.conf", fields["backup"]) +} + +func TestArchPkgbase(t *testing.T) { + info := exampleInfo() + info.ArchLinux.Pkgbase = "foo" + pkginfoData, err := makeTestPkginfo(t, info) + require.NoError(t, err) + fields := extractPkginfoFields(pkginfoData) + require.Equal(t, "foo", fields["pkgbase"]) +} + +func TestArchInvalidName(t *testing.T) { + info := exampleInfo() + info.Name = "#" + _, err := makeTestPkginfo(t, info) + require.ErrorIs(t, err, ErrInvalidPkgName) +} + +func TestArchVersionWithRelease(t *testing.T) { + info := exampleInfo() + info.Version = "0.0.1" + info.Release = "4" + pkginfoData, err := makeTestPkginfo(t, info) + require.NoError(t, err) + fields := extractPkginfoFields(pkginfoData) + require.Equal(t, "0.0.1-4", fields["pkgver"]) +} + +func TestArchVersionWithEpoch(t *testing.T) { + info := exampleInfo() + info.Version = "0.0.1" + info.Epoch = "2" + pkginfoData, err := makeTestPkginfo(t, info) + require.NoError(t, err) + fields := extractPkginfoFields(pkginfoData) + require.Equal(t, "2:0.0.1-1", fields["pkgver"]) +} + +func TestArchOverrideArchitecture(t *testing.T) { + info := exampleInfo() + info.ArchLinux.Arch = "randomarch" + pkginfoData, err := makeTestPkginfo(t, info) + require.NoError(t, err) + fields := extractPkginfoFields(pkginfoData) + require.Equal(t, "randomarch", fields["arch"]) +} + +func makeTestPkginfo(t *testing.T, info *nfpm.Info) ([]byte, error) { + t.Helper() + + buf := &bytes.Buffer{} + tw := tar.NewWriter(buf) + + entry, err := createPkginfo(info, tw, 1234) + if err != nil { + return nil, err + } + + tw.Close() + + tr := tar.NewReader(buf) + _, err = tr.Next() + require.NoError(t, err) + + pkginfoData := make([]byte, entry.Size) + _, err = io.ReadFull(tr, pkginfoData) + if err != nil { + return nil, err + } + + return pkginfoData, nil +} + +func extractPkginfoFields(data []byte) map[string]string { + strData := string(data) + strData = strings.TrimPrefix(strData, "# Generated by nfpm\n") + strData = strings.TrimSpace(strData) + + splitData := strings.Split(strData, "\n") + out := map[string]string{} + + for _, kvPair := range splitData { + splitPair := strings.Split(kvPair, " = ") + out[splitPair[0]] = splitPair[1] + } + + return out +} + +const correctMtree = `#mtree +./foo time=1234.0 type=dir +./3 time=12345.0 mode=644 size=100 type=file md5digest=abcd sha256digest=ef12 +./sh time=123456.0 mode=777 type=link link=/bin/bash +` + +func TestArchMtree(t *testing.T) { + info := exampleInfo() + + buf := &bytes.Buffer{} + tw := tar.NewWriter(buf) + + err := createMtree(info, tw, []MtreeEntry{ + { + Destination: "/foo", + Time: 1234, + Type: "dir", + }, + { + Destination: "/3", + Time: 12345, + Mode: 0o644, + Size: 100, + Type: "file", + MD5: []byte{0xAB, 0xCD}, + SHA256: []byte{0xEF, 0x12}, + }, + { + LinkSource: "/bin/bash", + Destination: "/sh", + Time: 123456, + Mode: 0o777, + Type: "symlink", + }, + }) + require.NoError(t, err) + + tw.Close() + + tr := tar.NewReader(buf) + _, err = tr.Next() + require.NoError(t, err) + + gr, err := pgzip.NewReader(tr) + require.NoError(t, err) + defer gr.Close() + + mtree, err := io.ReadAll(gr) + require.NoError(t, err) + + require.InDeltaSlice(t, []byte(correctMtree), mtree, 0) +} diff --git a/deb/deb.go b/deb/deb.go index 8415199..2008e45 100644 --- a/deb/deb.go +++ b/deb/deb.go @@ -84,6 +84,11 @@ func (*Deb) ConventionalFileName(info *nfpm.Info) string { return fmt.Sprintf("%s_%s_%s.deb", info.Name, version, info.Arch) } +// ConventionalExtension returns the file name conventionally used for Deb packages +func (*Deb) ConventionalExtension() string { + return ".deb" +} + // ErrInvalidSignatureType happens if the signature type of a deb is not one of // origin, maint or archive. var ErrInvalidSignatureType = errors.New("invalid signature type") diff --git a/internal/cmd/package.go b/internal/cmd/package.go index 044297b..0f8ac13 100644 --- a/internal/cmd/package.go +++ b/internal/cmd/package.go @@ -34,7 +34,7 @@ func newPackageCmd() *packageCmd { cmd.Flags().StringVarP(&root.config, "config", "f", "nfpm.yaml", "config file to be used") cmd.Flags().StringVarP(&root.target, "target", "t", "", "where to save the generated package (filename, folder or empty for current folder)") - cmd.Flags().StringVarP(&root.packager, "packager", "p", "", "which packager implementation to use [apk|deb|rpm]") + cmd.Flags().StringVarP(&root.packager, "packager", "p", "", "which packager implementation to use [apk|deb|rpm|archlinux]") root.cmd = cmd return root diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 92bb8fe..b917e02 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -4,6 +4,7 @@ import ( "fmt" _ "github.com/goreleaser/nfpm/v2/apk" + _ "github.com/goreleaser/nfpm/v2/arch" _ "github.com/goreleaser/nfpm/v2/deb" _ "github.com/goreleaser/nfpm/v2/rpm" "github.com/spf13/cobra" diff --git a/nfpm.go b/nfpm.go index 39ce9bd..0d159e6 100644 --- a/nfpm.go +++ b/nfpm.go @@ -106,6 +106,11 @@ type Packager interface { ConventionalFileName(info *Info) string } +type PackagerWithExtension interface { + Packager + ConventionalExtension() string +} + // Config contains the top level configuration for packages. type Config struct { Info `yaml:",inline" json:",inline"` @@ -294,6 +299,19 @@ type Overridables struct { RPM RPM `yaml:"rpm,omitempty" json:"rpm,omitempty" jsonschema:"title=rpm-specific settings"` Deb Deb `yaml:"deb,omitempty" json:"deb,omitempty" jsonschema:"title=deb-specific settings"` APK APK `yaml:"apk,omitempty" json:"apk,omitempty" jsonschema:"title=apk-specific settings"` + ArchLinux ArchLinux `yaml:"archlinux,omitempty" json:"archlinux,omitempty" jsonschema:"title=archlinux-specific settings"` +} + +type ArchLinux struct { + Pkgbase string `yaml:"pkgbase,omitempty" json:"pkgbase,omitempty" jsonschema:"title=explicitly specify the name used to refer to a split package, defaults to name"` + Arch string `yaml:"arch,omitempty" json:"arch,omitempty" jsonschema:"title=architecture in archlinux nomenclature"` + Packager string `yaml:"packager,omitempty" json:"packager,omitempty" jsonschema:"title=organization that packaged the software"` + Scripts ArchLinuxScripts `yaml:"scripts,omitempty" json:"scripts,omitempty" jsonschema:"title=archlinux-specific scripts"` +} + +type ArchLinuxScripts struct { + PreUpgrade string `yaml:"preupgrade,omitempty" json:"preupgrade,omitempty" jsonschema:"title=preupgrade script"` + PostUpgrade string `yaml:"postupgrade,omitempty" json:"postupgrade,omitempty" jsonschema:"title=postupgrade script"` } // RPM is custom configs that are only available on RPM packages. diff --git a/rpm/rpm.go b/rpm/rpm.go index fde0601..d9559ba 100644 --- a/rpm/rpm.go +++ b/rpm/rpm.go @@ -93,6 +93,11 @@ func (*RPM) ConventionalFileName(info *nfpm.Info) string { return fmt.Sprintf("%s-%s.%s.rpm", info.Name, version, info.Arch) } +// ConventionalExtension returns the file name conventionally used for RPM packages +func (*RPM) ConventionalExtension() string { + return ".rpm" +} + // Package writes a new RPM package to the given writer using the given info. func (*RPM) Package(info *nfpm.Info, w io.Writer) (err error) { var ( diff --git a/testdata/acceptance/archlinux.dockerfile b/testdata/acceptance/archlinux.dockerfile new file mode 100644 index 0000000..75e5ab8 --- /dev/null +++ b/testdata/acceptance/archlinux.dockerfile @@ -0,0 +1,131 @@ +FROM archlinux AS test_base +ARG package +RUN echo "${package}" +COPY ${package} /tmp/foo.pkg.tar.zst + + +# ---- minimal test ---- +FROM test_base AS min +RUN pacman --noconfirm -U /tmp/foo.pkg.tar.zst + + +# ---- symlink test ---- +FROM min AS symlink +RUN ls -l /path/to/symlink | grep "/path/to/symlink -> /etc/foo/whatever.conf" + + +# ---- simple test ---- +FROM min AS simple +RUN test -e /usr/local/bin/fake +RUN test -f /etc/foo/whatever.conf +RUN echo wat >> /etc/foo/whatever.conf +RUN pacman --noconfirm -R foo +RUN test -f /etc/foo/whatever.conf.pacsave +RUN test ! -f /usr/local/bin/fake + + +# ---- no-glob test ---- +FROM min AS no-glob +RUN test -d /usr/share/whatever/ +RUN test -f /usr/share/whatever/file1 +RUN test -f /usr/share/whatever/file2 +RUN test -d /usr/share/whatever/folder2 +RUN test -f /usr/share/whatever/folder2/file1 +RUN test -f /usr/share/whatever/folder2/file2 + + +# ---- complex test ---- +FROM min AS complex +RUN pacman -Qi foo | grep "Depends On\\s*: bash" +RUN pacman -Qi foo | grep "Replaces\\s*: foo" +RUN pacman -Qi foo | grep "Provides\\s*: fake" +RUN test -e /usr/local/bin/fake +RUN test -f /etc/foo/whatever.conf +RUN test -d /usr/share/whatever/ +RUN test -d /usr/share/whatever/folder +RUN test -f /usr/share/whatever/folder/file1 +RUN test -f /usr/share/whatever/folder/file2 +RUN test -d /usr/share/whatever/folder/folder2 +RUN test -f /usr/share/whatever/folder/folder2/file1 +RUN test -f /usr/share/whatever/folder/folder2/file2 +RUN test -d /var/log/whatever +RUN test -d /usr/share/foo +RUN test -d /usr/foo/bar/something +RUN test $(stat -c %a /usr/bin/fake) -eq 4755 +RUN test -f /tmp/preinstall-proof +RUN test -f /tmp/postinstall-proof +RUN test ! -f /tmp/preremove-proof +RUN test ! -f /tmp/postremove-proof +RUN echo wat >> /etc/foo/whatever.conf +RUN pacman --noconfirm -R foo +RUN test -f /etc/foo/whatever.conf.pacsave +RUN test ! -f /usr/local/bin/fake +RUN test ! -f /usr/bin/fake +RUN test -f /tmp/preremove-proof +RUN test -f /tmp/postremove-proof +RUN test ! -d /var/log/whatever +RUN test ! -d /usr/share/foo +RUN test ! -d /usr/foo/bar/something + + +# ---- signed test ---- +FROM min AS signed +RUN echo "Arch Linux has no signature support" + + +# ---- overrides test ---- +FROM min AS overrides +RUN test -e /usr/local/bin/fake +RUN test -f /etc/foo/whatever.conf +RUN test ! -f /tmp/preinstall-proof +RUN test -f /tmp/postinstall-proof +RUN test ! -f /tmp/preremove-proof +RUN test ! -f /tmp/postremove-proof +RUN echo wat >> /etc/foo/whatever.conf +RUN pacman --noconfirm -R foo +RUN test -f /etc/foo/whatever.conf.pacsave +RUN test ! -f /usr/local/bin/fake +RUN test -f /tmp/preremove-proof +RUN test ! -f /tmp/postremove-proof + + +# ---- meta test ---- +FROM test_base AS meta +RUN pacman -Sy && pacman --noconfirm -U /tmp/foo.pkg.tar.zst +RUN command -v zsh + + +# ---- env-var-version test ---- +FROM min AS env-var-version +ENV EXPECTVER="foo 1.0.0-1" +RUN export FOUND_VER="$(pacman -Q foo)" && \ + echo "Expected: '${EXPECTVER}' :: Found: '${FOUND_VER}'" && \ + test "${FOUND_VER}" = "${EXPECTVER}" + + +# ---- changelog test ---- +FROM min AS withchangelog +RUN echo "Arch Linux has no changelog support" + + +# ---- upgrade test ---- +FROM test_base AS upgrade +ARG oldpackage +RUN echo "${oldpackage}" +COPY ${oldpackage} /tmp/old_foo.pkg.tar.zst +RUN pacman --noconfirm -U /tmp/old_foo.pkg.tar.zst +RUN test -f /tmp/preinstall-proof +RUN cat /tmp/preinstall-proof | grep "Install" +RUN test -f /tmp/postinstall-proof +RUN cat /tmp/postinstall-proof | grep "Install" +RUN test ! -f /tmp/preupgrade-proof +RUN test ! -f /tmp/postupgrade-proof +RUN echo modified > /etc/regular.conf +RUN echo modified > /etc/noreplace.conf +RUN pacman --noconfirm -U /tmp/foo.pkg.tar.zst +RUN test -f /tmp/preupgrade-proof +RUN test -f /tmp/postupgrade-proof +RUN test -f /etc/regular.conf +RUN test -f /etc/regular.conf.pacnew +RUN test -f /etc/noreplace.conf +RUN test -f /etc/noreplace.conf.pacnew \ No newline at end of file diff --git a/testdata/acceptance/core.complex.yaml b/testdata/acceptance/core.complex.yaml index 48e0114..6e302ed 100644 --- a/testdata/acceptance/core.complex.yaml +++ b/testdata/acceptance/core.complex.yaml @@ -29,8 +29,24 @@ contents: type: config - src: ./testdata/fake dst: /usr/sbin/fake + packager: deb file_info: mode: 04755 +- src: ./testdata/fake + dst: /usr/sbin/fake + packager: rpm + file_info: + mode: 04755 +- src: ./testdata/fake + dst: /usr/sbin/fake + packager: apk + file_info: + mode: 04755 +- src: ./testdata/fake + dst: /usr/bin/fake + file_info: + mode: 04755 + packager: archlinux - dst: /usr/foo/bar/something type: dir - dst: /var/log/whatever @@ -50,3 +66,7 @@ apk: scripts: preupgrade: ./testdata/acceptance/scripts/preupgrade.sh postupgrade: ./testdata/acceptance/scripts/postupgrade.sh +archlinux: + scripts: + preupgrade: ./testdata/acceptance/scripts/preupgrade.sh + postupgrade: ./testdata/acceptance/scripts/postupgrade.sh \ No newline at end of file diff --git a/testdata/acceptance/core.overrides.yaml b/testdata/acceptance/core.overrides.yaml index 32177fc..8d2af89 100644 --- a/testdata/acceptance/core.overrides.yaml +++ b/testdata/acceptance/core.overrides.yaml @@ -44,3 +44,7 @@ overrides: scripts: postinstall: ./testdata/acceptance/scripts/postinstall.sh preremove: ./testdata/acceptance/scripts/preremove.sh + archlinux: + scripts: + postinstall: ./testdata/acceptance/scripts/postinstall.sh + preremove: ./testdata/acceptance/scripts/preremove.sh \ No newline at end of file diff --git a/testdata/acceptance/upgrade.v1.yaml b/testdata/acceptance/upgrade.v1.yaml index bcbed9e..d69ec75 100644 --- a/testdata/acceptance/upgrade.v1.yaml +++ b/testdata/acceptance/upgrade.v1.yaml @@ -39,3 +39,7 @@ apk: scripts: preupgrade: ./testdata/acceptance/scripts/preupgrade.sh postupgrade: ./testdata/acceptance/scripts/postupgrade.sh +archlinux: + scripts: + preupgrade: ./testdata/acceptance/scripts/preupgrade.sh + postupgrade: ./testdata/acceptance/scripts/postupgrade.sh \ No newline at end of file diff --git a/testdata/acceptance/upgrade.v2.yaml b/testdata/acceptance/upgrade.v2.yaml index 09c1172..5889960 100644 --- a/testdata/acceptance/upgrade.v2.yaml +++ b/testdata/acceptance/upgrade.v2.yaml @@ -39,3 +39,7 @@ apk: scripts: preupgrade: ./testdata/acceptance/scripts/preupgrade.sh postupgrade: ./testdata/acceptance/scripts/postupgrade.sh +archlinux: + scripts: + preupgrade: ./testdata/acceptance/scripts/preupgrade.sh + postupgrade: ./testdata/acceptance/scripts/postupgrade.sh \ No newline at end of file diff --git a/www/docs/cmd/nfpm_package.md b/www/docs/cmd/nfpm_package.md index 60ba6ac..4990a11 100644 --- a/www/docs/cmd/nfpm_package.md +++ b/www/docs/cmd/nfpm_package.md @@ -11,7 +11,7 @@ nfpm package [flags] ``` -f, --config string config file to be used (default "nfpm.yaml") -h, --help help for package - -p, --packager string which packager implementation to use [apk|deb|rpm] + -p, --packager string which packager implementation to use [apk|deb|rpm|archlinux] -t, --target string where to save the generated package (filename, folder or empty for current folder) ``` diff --git a/www/docs/configuration.md b/www/docs/configuration.md index 7501d13..6731bc3 100644 --- a/www/docs/configuration.md +++ b/www/docs/configuration.md @@ -231,6 +231,10 @@ overrides: # ... apk: # ... + archlinux: + depends: + - baz + - some-lib # Custom configuration applied only to the RPM packager. rpm: @@ -347,6 +351,24 @@ apk: key_name: origin # APK does not use pgp keys, so the key_id field is ignored. key_id: ignored + +archlinux: + # This value is used to specify the name used to refer to a group + # of packages when building a split package. Defaults to name + # See: https://wiki.archlinux.org/title/PKGBUILD#pkgbase + pkgbase: bar + # The packager identifies the organization packaging the software + # rather than the developer. Defaults to "Unknown Packager". + packager: GoReleaser + + # Arch Linux specific scripts. + scripts: + # The postupgrade script runs before pacman upgrades the package + preupgrade: ./scripts/preupgrade.sh + # The postupgrade script runs after pacman upgrades the package + postupgrade: ./scripts/postupgrade.sh + + ``` ## Templating diff --git a/www/docs/index.md b/www/docs/index.md index 683bf0b..c5ac7c3 100644 --- a/www/docs/index.md +++ b/www/docs/index.md @@ -2,7 +2,7 @@ ![](/static/banner.svg) -nFPM is a simple, 0-dependencies, `deb`, `rpm` and `apk` packager. +nFPM is a simple, 0-dependencies, `deb`, `rpm`, `apk`, and Arch Linux packager. ## Why