diff --git a/Dockerfile b/Dockerfile index 560303236..8075aeee2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,7 @@ ENV RELEASE_VERSION=$RELEASE_VERSION RUN export PATH=$PATH:$GOPATH/bin && \ mage build:clean && \ - mage release:xgo "${TARGETOS}/${TARGETARCH}/${TARGETVARIANT}" + (cd build && mage release:xgo vikunja "${TARGETOS}/${TARGETARCH}/${TARGETVARIANT}") RUN mkdir -p /tmp && chmod 1777 /tmp diff --git a/magefile.go b/magefile.go index 5996ee8a7..2d546d378 100644 --- a/magefile.go +++ b/magefile.go @@ -43,7 +43,6 @@ import ( "github.com/iancoleman/strcase" "github.com/magefile/mage/mg" - "golang.org/x/sync/errgroup" ) const ( @@ -62,27 +61,21 @@ var ( // Aliases are mage aliases of targets Aliases = map[string]any{ - "build": Build.Build, - "check:got-swag": Check.GotSwag, - "release": Release.Release, - "release:os-package": Release.OsPackage, - "release:prepare-nfpm-config": Release.PrepareNFPMConfig, - "release:repo-apt": Release.RepoApt, - "release:repo-rpm": Release.RepoRpm, - "release:repo-pacman": Release.RepoPacman, - "dev:make-migration": Dev.MakeMigration, - "dev:make-event": Dev.MakeEvent, - "dev:make-listener": Dev.MakeListener, - "dev:make-notification": Dev.MakeNotification, - "dev:prepare-worktree": Dev.PrepareWorktree, - "dev:tag-release": Dev.TagRelease, - "test:e2e": Test.E2E, - "test:e2e-api": Test.E2EApi, - "plugins:build": Plugins.Build, - "lint": Check.Golangci, - "lint:fix": Check.GolangciFix, - "generate:config-yaml": Generate.ConfigYAML, - "generate:swagger-docs": Generate.SwaggerDocs, + "build": Build.Build, + "check:got-swag": Check.GotSwag, + "dev:make-migration": Dev.MakeMigration, + "dev:make-event": Dev.MakeEvent, + "dev:make-listener": Dev.MakeListener, + "dev:make-notification": Dev.MakeNotification, + "dev:prepare-worktree": Dev.PrepareWorktree, + "dev:tag-release": Dev.TagRelease, + "test:e2e": Test.E2E, + "test:e2e-api": Test.E2EApi, + "plugins:build": Plugins.Build, + "lint": Check.Golangci, + "lint:fix": Check.GolangciFix, + "generate:config-yaml": Generate.ConfigYAML, + "generate:swagger-docs": Generate.SwaggerDocs, } ) @@ -268,45 +261,6 @@ func copyFile(src, dst string) error { return out.Close() } -// os.Rename has issues with moving files between docker volumes. -// Because of this limitation, it fails in drone. -// Source: https://gist.github.com/var23rav/23ae5d0d4d830aff886c3c970b8f6c6b -func moveFile(src, dst string) error { - inputFile, err := os.Open(src) - if err != nil { - return fmt.Errorf("couldn't open source file: %w", err) - } - defer inputFile.Close() - - outputFile, err := os.Create(dst) - if err != nil { - return fmt.Errorf("couldn't open dest file: %w", err) - } - defer outputFile.Close() - - _, err = io.Copy(outputFile, inputFile) - if err != nil { - return fmt.Errorf("writing to output file failed: %w", err) - } - - // Make sure to copy copy the permissions of the original file as well - si, err := os.Stat(src) - if err != nil { - return err - } - - if err := os.Chmod(dst, si.Mode()); err != nil { - return err - } - - // The copy was successful, so now delete the original file - err = os.Remove(src) - if err != nil { - return fmt.Errorf("failed removing original file: %w", err) - } - return nil -} - func appendToFile(filename, content string) error { f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600) if err != nil { @@ -1180,624 +1134,6 @@ func (Build) SaveVersionToFile() error { return nil } -type Release mg.Namespace - -// Release runs all steps in the right order to create release packages for various platforms -func (Release) Release(ctx context.Context) error { - mg.Deps(initVars) - mg.Deps(Release.Dirs, prepareXgo) - - // Run compiling in parallel to speed it up - errs, _ := errgroup.WithContext(ctx) - errgroupGoWithContext(ctx, errs, (Release{}).Windows) - errgroupGoWithContext(ctx, errs, (Release{}).Linux) - errgroupGoWithContext(ctx, errs, (Release{}).Darwin) - if err := errs.Wait(); err != nil { - return err - } - - if err := (Release{}).Compress(ctx); err != nil { - return err - } - if err := (Release{}).Copy(); err != nil { - return err - } - if err := (Release{}).Check(); err != nil { - return err - } - if err := (Release{}).OsPackage(); err != nil { - return err - } - if err := (Release{}).Zip(ctx); err != nil { - return err - } - - return nil -} - -func errgroupGoWithContext(ctx context.Context, errs *errgroup.Group, do func(context.Context) error) { - errs.Go(func() error { - return do(ctx) - }) -} - -// Dirs creates all directories needed to release vikunja -func (Release) Dirs() error { - for _, d := range []string{"binaries", "release", "zip"} { - if err := os.MkdirAll("./"+DIST+"/"+d, 0o755); err != nil { - return err - } - } - return nil -} - -func prepareXgo(ctx context.Context) error { - mg.Deps(initVars) - if err := checkAndInstallGoTool(ctx, "xgo", "src.techknowlogick.com/xgo"); err != nil { - return err - } - - fmt.Println("Pulling latest xgo docker image...") - return runAndStreamOutput(ctx, "docker", "pull", "ghcr.io/techknowlogick/xgo:latest") -} - -func runXgo(ctx context.Context, targets string) error { - mg.Deps(initVars) - if err := checkAndInstallGoTool(ctx, "xgo", "src.techknowlogick.com/xgo"); err != nil { - return err - } - - extraLdflags := `-linkmode external -extldflags "-static" ` - - // See https://github.com/techknowlogick/xgo/issues/79 - if strings.HasPrefix(targets, "darwin") { - extraLdflags = "" - } - outName := os.Getenv("XGO_OUT_NAME") - if outName == "" { - outName = Executable + "-" + Version - } - - if err := runAndStreamOutput(ctx, "xgo", - "-dest", "./"+DIST+"/binaries", - "-tags", "netgo "+Tags, - "-ldflags", extraLdflags+Ldflags, - "-targets", targets, - "-out", outName, - "."); err != nil { - return err - } - if os.Getenv("DRONE_WORKSPACE") != "" { - return filepath.Walk("/build/", func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - // Skip directories - if info.IsDir() { - return nil - } - - return moveFile(path, "./"+DIST+"/binaries/"+info.Name()) - }) - } - return nil -} - -// Windows builds binaries for windows -func (Release) Windows(ctx context.Context) error { - return runXgo(ctx, "windows/*") -} - -// Linux builds binaries for linux -func (Release) Linux(ctx context.Context) error { - targets := []string{ - "linux/amd64", - "linux/arm-5", - "linux/arm-6", - "linux/arm-7", - "linux/arm64", - "linux/mips", - "linux/mipsle", - "linux/mips64", - "linux/mips64le", - "linux/riscv64", - } - return runXgo(ctx, strings.Join(targets, ",")) -} - -// Darwin builds binaries for darwin -func (Release) Darwin(ctx context.Context) error { - return runXgo(ctx, "darwin-10.15/*") -} - -func (Release) Xgo(ctx context.Context, target string) error { - parts := strings.Split(target, "/") - if len(parts) < 2 { - return fmt.Errorf("invalid target") - } - - variant := "" - if len(parts) > 2 && parts[2] != "" { - variant = "-" + strings.ReplaceAll(parts[2], "v", "") - } - - return runXgo(ctx, parts[0]+"/"+parts[1]+variant) -} - -// Compress compresses the built binaries in dist/binaries/ to reduce their filesize -func (Release) Compress(ctx context.Context) error { - // $(foreach file,$(filter-out $(wildcard $(wildcard $(DIST)/binaries/$(EXECUTABLE)-*mips*)),$(wildcard $(DIST)/binaries/$(EXECUTABLE)-*)), upx -9 $(file);) - - errs, _ := errgroup.WithContext(ctx) - - walkErr := filepath.Walk("./"+DIST+"/binaries/", func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - // Only executable files - if !strings.Contains(info.Name(), Executable) { - return nil - } - if strings.Contains(info.Name(), "mips") || - strings.Contains(info.Name(), "s390x") || - strings.Contains(info.Name(), "riscv64") || - strings.Contains(info.Name(), "darwin") || - (strings.Contains(info.Name(), "windows") && strings.Contains(info.Name(), "arm64")) { - // not supported by upx - return nil - } - - // Runs compressing in parallel since upx is single-threaded - errs.Go(func() error { - if err := runAndStreamOutput(ctx, "chmod", "+x", path); err != nil { // Make sure all binaries are executable. Sometimes the CI does weird things and they're not. - return err - } - return runAndStreamOutput(ctx, "upx", "-9", path) - }) - - return nil - }) - if walkErr != nil { - return walkErr - } - return errs.Wait() -} - -// Copy copies all built binaries to dist/release/ in preparation for creating the os packages -func (Release) Copy() error { - return filepath.Walk("./"+DIST+"/binaries/", func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - // Only executable files - if !strings.Contains(info.Name(), Executable) { - return nil - } - - return copyFile(path, "./"+DIST+"/release/"+info.Name()) - }) -} - -// Check creates sha256 checksum files for each binary in dist/release/ -func (Release) Check() error { - p := "./" + DIST + "/release/" - return filepath.Walk(p, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { - return nil - } - - f, err := os.Create(p + info.Name() + ".sha256") - if err != nil { - return err - } - - hash, err := calculateSha256FileHash(path) - if err != nil { - return err - } - - _, err = f.WriteString(hash + " " + info.Name()) - if err != nil { - return err - } - - return f.Close() - }) -} - -// OsPackage creates a folder for each -func (Release) OsPackage() error { - p := "./" + DIST + "/release/" - - // We first put all files in a map to then iterate over it since the walk function would otherwise also iterate - // over the newly created files, creating some kind of endless loop. - bins := make(map[string]os.FileInfo) - if err := filepath.Walk(p, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if strings.Contains(info.Name(), ".sha256") || info.IsDir() { - return nil - } - bins[path] = info - return nil - }); err != nil { - return err - } - - generateConfigYAMLFromJSON("./"+DefaultConfigYAMLSamplePath, true) - - for path, info := range bins { - folder := p + info.Name() + "-full/" - if err := os.Mkdir(folder, 0o755); err != nil { - return err - } - if err := moveFile(p+info.Name()+".sha256", folder+info.Name()+".sha256"); err != nil { - return err - } - if err := moveFile(path, folder+info.Name()); err != nil { - return err - } - if err := copyFile("./"+DefaultConfigYAMLSamplePath, folder+DefaultConfigYAMLSamplePath); err != nil { - return err - } - if err := copyFile("./LICENSE", folder+"LICENSE"); err != nil { - return err - } - } - return nil -} - -// Zip creates a zip file from all os-package folders in dist/release -func (Release) Zip(ctx context.Context) error { - rootDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("could not get working directory: %w", err) - } - - p := "./" + DIST + "/release/" - if err := filepath.Walk(p, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if !info.IsDir() || info.Name() == "release" { - return nil - } - - fmt.Printf("Zipping %s...\n", info.Name()) - - zipFile := filepath.Join(rootDir, DIST, "zip", info.Name()+".zip") - c := exec.CommandContext(ctx, "zip", "-r", zipFile, ".", "-i", "*") //nolint:gosec // This mage task creates zips of every directory recursively, it must use the directory name in the resulting file path to distinguish output files. - c.Dir = path - out, err := c.Output() - fmt.Print(string(out)) - return err - }); err != nil { - return err - } - - return nil -} - -// repoSuite returns a validated suite name from the REPO_SUITE env var. -// Only "stable" and "unstable" are allowed to prevent path traversal. -func repoSuite() string { - suite := os.Getenv("REPO_SUITE") - switch suite { - case "stable", "unstable": - return suite - default: - return "stable" - } -} - -// RepoApt generates APT repository metadata using reprepro. -// It expects .deb files in /repo-work/incoming/ and outputs to /repo-output/apt/. -// The reprepro config is read from build/reprepro-dist-conf. -// Signing is done manually after reprepro finishes to avoid gpgme pinentry issues in CI. -// Environment: REPO_SUITE controls the target suite (default: "stable"). -// Environment: RELEASE_GPG_KEY, RELEASE_GPG_PASSPHRASE must be set for signing. -func (Release) RepoApt(ctx context.Context) error { - mg.Deps(initVars) - - suite := repoSuite() - - incomingDir := filepath.Join(DIST, "repo-work", "incoming") - outputBase := filepath.Join(DIST, "repo-output", "apt") - - // Set up reprepro conf directory - confDir := filepath.Join(outputBase, "conf") - if err := os.MkdirAll(confDir, 0o755); err != nil { - return fmt.Errorf("creating reprepro conf dir: %w", err) - } - - // Copy distributions config - distConf, err := os.ReadFile("build/reprepro-dist-conf") - if err != nil { - return fmt.Errorf("reading reprepro-dist-conf: %w", err) - } - if err := os.WriteFile(filepath.Join(confDir, "distributions"), distConf, 0o600); err != nil { - return fmt.Errorf("writing distributions config: %w", err) - } - - // Include all .deb files into the target suite - debs, err := filepath.Glob(filepath.Join(incomingDir, "*.deb")) - if err != nil { - return err - } - for _, deb := range debs { - abs, _ := filepath.Abs(deb) - if err := runAndStreamOutput(ctx, "reprepro", - "-b", outputBase, - "includedeb", suite, - abs, - ); err != nil { - return fmt.Errorf("reprepro includedeb %s: %w", filepath.Base(deb), err) - } - } - - // Sign Release files manually (reprepro's gpgme signing doesn't work in CI) - gpgKey := os.Getenv("RELEASE_GPG_KEY") - gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE") - - releaseFile := filepath.Join(outputBase, "dists", suite, "Release") - if _, err := os.Stat(releaseFile); err == nil { - // Generate Release.gpg (detached signature) - if err := runAndStreamOutput(ctx, "gpg", - "--default-key", gpgKey, - "--batch", "--yes", - "--passphrase", gpgPassphrase, - "--pinentry-mode", "loopback", - "--detach-sign", "--armor", - "-o", releaseFile+".gpg", - releaseFile, - ); err != nil { - return fmt.Errorf("signing Release (detached): %w", err) - } - - // Generate InRelease (clearsigned) - if err := runAndStreamOutput(ctx, "gpg", - "--default-key", gpgKey, - "--batch", "--yes", - "--passphrase", gpgPassphrase, - "--pinentry-mode", "loopback", - "--clearsign", - "-o", filepath.Join(filepath.Dir(releaseFile), "InRelease"), - releaseFile, - ); err != nil { - return fmt.Errorf("signing Release (clearsign): %w", err) - } - } - - fmt.Println("APT repo metadata generated in", outputBase) - return nil -} - -// RepoRpm generates RPM repository metadata for all .rpm files in the work directory. -// Expects .rpm files in /repo-work/incoming/ and outputs to /repo-output/rpm//. -// Environment: RELEASE_GPG_KEY, RELEASE_GPG_PASSPHRASE must be set for signing. -// Environment: REPO_SUITE controls the target suite (default: "stable"). -func (Release) RepoRpm(ctx context.Context) error { - mg.Deps(initVars) - - suite := repoSuite() - - incomingDir := filepath.Join(DIST, "repo-work", "incoming") - outputBase := filepath.Join(DIST, "repo-output", "rpm", suite) - - archMap := map[string]string{ - "x86_64": "x86_64", - "aarch64": "aarch64", - "armv7": "armv7", - } - - gpgKey := os.Getenv("RELEASE_GPG_KEY") - gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE") - - for pkgArch, repoArch := range archMap { - repoDir := filepath.Join(outputBase, repoArch) - if err := os.MkdirAll(repoDir, 0o755); err != nil { - return err - } - - // Symlink matching RPMs - pattern := filepath.Join(incomingDir, "*-"+pkgArch+".rpm") - rpms, _ := filepath.Glob(pattern) - if len(rpms) == 0 { - continue - } - for _, rpm := range rpms { - abs, _ := filepath.Abs(rpm) - dst := filepath.Join(repoDir, filepath.Base(rpm)) - os.Remove(dst) - if err := os.Symlink(abs, dst); err != nil { - return err - } - } - - // createrepo_c (--update if repodata already exists) - args := []string{repoDir} - if _, err := os.Stat(filepath.Join(repoDir, "repodata")); err == nil { - args = []string{"--update", repoDir} - } - if err := runAndStreamOutput(ctx, "createrepo_c", args...); err != nil { - return fmt.Errorf("createrepo_c for %s: %w", repoArch, err) - } - - // Sign repomd.xml - if err := runAndStreamOutput(ctx, "gpg", - "--default-key", gpgKey, - "--batch", "--yes", - "--passphrase", gpgPassphrase, - "--pinentry-mode", "loopback", - "--detach-sign", "--armor", - "-o", filepath.Join(repoDir, "repodata", "repomd.xml.asc"), - filepath.Join(repoDir, "repodata", "repomd.xml"), - ); err != nil { - return fmt.Errorf("signing repomd.xml for %s: %w", repoArch, err) - } - } - - fmt.Println("RPM repo metadata generated in", outputBase) - return nil -} - -// RepoPacman generates Pacman repository database for all .archlinux files in the work directory. -// Expects .archlinux files in /repo-work/incoming/ and outputs to /repo-output/pacman//. -// Environment: RELEASE_GPG_KEY, RELEASE_GPG_PASSPHRASE must be set for signing. -// Environment: REPO_SUITE controls the target suite (default: "stable"). -func (Release) RepoPacman(ctx context.Context) error { - mg.Deps(initVars) - - suite := repoSuite() - - incomingDir := filepath.Join(DIST, "repo-work", "incoming") - outputBase := filepath.Join(DIST, "repo-output", "pacman", suite) - - archMap := map[string]string{ - "x86_64": "x86_64", - "aarch64": "aarch64", - "armv7": "armv7", - } - - gpgKey := os.Getenv("RELEASE_GPG_KEY") - gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE") - - for pkgArch, repoArch := range archMap { - repoDir := filepath.Join(outputBase, repoArch) - if err := os.MkdirAll(repoDir, 0o755); err != nil { - return err - } - - pattern := filepath.Join(incomingDir, "*-"+pkgArch+".archlinux") - pkgs, _ := filepath.Glob(pattern) - if len(pkgs) == 0 { - continue - } - for _, pkg := range pkgs { - abs, _ := filepath.Abs(pkg) - dst := filepath.Join(repoDir, filepath.Base(pkg)) - os.Remove(dst) - if err := os.Symlink(abs, dst); err != nil { - return err - } - } - - // repo-add creates vikunja.db.tar.gz and vikunja.files.tar.gz - dbPath := filepath.Join(repoDir, "vikunja.db.tar.gz") - repoPkgs, _ := filepath.Glob(filepath.Join(repoDir, "*.archlinux")) - repoAddArgs := append([]string{dbPath}, repoPkgs...) - if err := runAndStreamOutput(ctx, "repo-add", repoAddArgs...); err != nil { - return fmt.Errorf("repo-add for %s: %w", repoArch, err) - } - - // Create conventional symlinks (vikunja.db -> vikunja.db.tar.gz) - for _, name := range []string{"vikunja.db", "vikunja.files"} { - link := filepath.Join(repoDir, name) - os.Remove(link) - if err := os.Symlink(name+".tar.gz", link); err != nil { - return fmt.Errorf("creating symlink %s: %w", name, err) - } - } - - // Sign the database - if err := runAndStreamOutput(ctx, "gpg", - "--default-key", gpgKey, - "--batch", "--yes", - "--passphrase", gpgPassphrase, - "--pinentry-mode", "loopback", - "--detach-sign", - "-o", filepath.Join(repoDir, "vikunja.db.sig"), - dbPath, - ); err != nil { - return fmt.Errorf("signing db for %s: %w", repoArch, err) - } - } - - fmt.Println("Pacman repo metadata generated in", outputBase) - return nil -} - -// PrepareNFPMConfig prepares the nfpm config -func (Release) PrepareNFPMConfig() error { - mg.Deps(initVars) - var err error - - // Because nfpm does not support templating, we replace the values in the config file and restore it after running - nfpmConfigPath := "./nfpm.yaml" - nfpmconfig, err := os.ReadFile(nfpmConfigPath) - if err != nil { - return err - } - - var nfpmArch string - switch os.Getenv("NFPM_ARCH") { - case "arm64": - nfpmArch = "arm64" - case "arm7": - nfpmArch = "arm7" - case "386": - nfpmArch = "386" - default: - nfpmArch = "amd64" - } - - fixedConfig := strings.ReplaceAll(string(nfpmconfig), "", VersionNumber) - fixedConfig = strings.ReplaceAll(fixedConfig, "", BinLocation) - fixedConfig = strings.ReplaceAll(fixedConfig, "", nfpmArch) - if err := os.WriteFile(nfpmConfigPath, []byte(fixedConfig), 0); err != nil { - return err - } - - generateConfigYAMLFromJSON(DefaultConfigYAMLSamplePath, true) - - return nil -} - -// Packages creates deb, rpm and apk packages -func (Release) Packages(ctx context.Context) error { - mg.Deps(initVars) - - var err error - binpath := os.Getenv("NFPM_BIN_PATH") - if binpath == "" { - binpath = "nfpm" - } - err = exec.CommandContext(ctx, binpath).Run() - if err != nil && strings.Contains(err.Error(), "executable file not found") { - binpath = "/usr/bin/nfpm" - err = exec.CommandContext(ctx, binpath).Run() - } - if err != nil && strings.Contains(err.Error(), "executable file not found") { - return fmt.Errorf("executable %s not found: please manually install nfpm by running the command: curl -sfL https://install.goreleaser.com/github.com/goreleaser/nfpm.sh | sh -s -- -b $(go env GOPATH)/bin", binpath) - } - - err = (Release{}).PrepareNFPMConfig() - if err != nil { - return err - } - - releasePath := "./" + DIST + "/os-packages/" - if err := os.MkdirAll(releasePath, 0o755); err != nil { - return err - } - - if err := runAndStreamOutput(ctx, binpath, "pkg", "--packager", "deb", "--target", releasePath); err != nil { - return err - } - if err := runAndStreamOutput(ctx, binpath, "pkg", "--packager", "rpm", "--target", releasePath); err != nil { - return err - } - if err := runAndStreamOutput(ctx, binpath, "pkg", "--packager", "apk", "--target", releasePath); err != nil { - return err - } - - return nil -} - type Dev mg.Namespace // MakeMigration creates a new bare db migration skeleton in pkg/migration. diff --git a/veans/magefile.go b/veans/magefile.go index 814960ccd..2e8e50206 100644 --- a/veans/magefile.go +++ b/veans/magefile.go @@ -16,21 +16,18 @@ //go:build mage -// Mage targets for the veans CLI. Patterned after the parent monorepo's -// magefile (Build/Test/Lint namespaces), but scoped to this submodule. +// Mage targets for the veans CLI: dev build, tests, lint. +// +// Release tooling (xgo cross-compile, packaging, nfpm templating) lives in +// the centralized build/ module — run "mage release:build veans" from there. package main import ( - "context" - "crypto/sha256" "fmt" - "io" "os" "os/exec" "path/filepath" "runtime" - "strings" - "sync" "github.com/magefile/mage/mg" "github.com/magefile/mage/sh" @@ -47,9 +44,9 @@ func Build() error { // Clean removes built artifacts. func Clean() error { - for _, p := range []string{"./veans", "./veans.exe", "./" + releaseDist} { + for _, p := range []string{"./veans", "./veans.exe"} { if _, err := os.Stat(p); err == nil { - if err := os.RemoveAll(p); err != nil { + if err := os.Remove(p); err != nil { return err } } @@ -127,466 +124,12 @@ func (Lint) Fix() error { return sh.RunV("golangci-lint", "run", "--fix", "./...") } -// ----------------------------------------------------------------------------- -// Release -// -// Cross-compiles the veans binary for every OS/arch the parent vikunja binary -// targets, runs upx where supported, bundles each into a zip with the LICENSE -// and a sha256, and templates nfpm.yaml so the CI can build deb/rpm/apk/ -// archlinux packages from the same artifacts. Everything lands under -// `/dist/`. The CI workflow uploads dist/zip/* to S3 /veans// and -// hands dist/binaries/* off to the nfpm job. - -const releaseDist = "dist" - -type Release mg.Namespace - -var ( - releaseVersionNumber string - releaseVersionString string - releaseLdflags string - releaseTags = "netgo osusergo" - releaseInitOnce sync.Once - releaseInitErr error -) - -func releaseInitVars(ctx context.Context) error { - releaseInitOnce.Do(func() { - num := os.Getenv("RELEASE_VERSION") - if num == "" { - out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--always", "--abbrev=10").Output() - if err != nil { - releaseInitErr = fmt.Errorf("git describe: %w", err) - return - } - num = strings.TrimSpace(string(out)) - } - releaseVersionNumber = strings.Replace(strings.Trim(num, "\n"), "-g", "-", 1) - switch releaseVersionNumber { - case "", "main": - releaseVersionString = "unstable" - default: - releaseVersionString = releaseVersionNumber - } - releaseLdflags = fmt.Sprintf(`-X main.version=%s`, releaseVersionNumber) - }) - return releaseInitErr -} - -// Release runs all release steps end-to-end: dirs → xgo (windows/linux/darwin -// in parallel) → upx → copy → sha256 → per-target bundle dirs → zip. -func (Release) Release(ctx context.Context) error { - mg.Deps(releaseInitVars) - if err := (Release{}).Dirs(); err != nil { - return err - } - if err := releasePrepareXgo(ctx); err != nil { - return err - } - - // Run cross-compilation per OS in parallel; xgo serializes targets - // inside the docker container so each OS still gets full CPU. - var wg sync.WaitGroup - var ( - mu sync.Mutex - firstErr error - ) - record := func(err error) { - if err == nil { - return - } - mu.Lock() - if firstErr == nil { - firstErr = err - } - mu.Unlock() - } - for _, fn := range []func(context.Context) error{ - (Release{}).Windows, - (Release{}).Linux, - (Release{}).Darwin, - } { - wg.Add(1) - go func(f func(context.Context) error) { - defer wg.Done() - record(f(ctx)) - }(fn) - } - wg.Wait() - if firstErr != nil { - return firstErr - } - - if err := (Release{}).Compress(ctx); err != nil { - return err - } - if err := (Release{}).Copy(); err != nil { - return err - } - if err := (Release{}).Check(); err != nil { - return err - } - if err := (Release{}).OsPackage(); err != nil { - return err - } - return (Release{}).Zip(ctx) -} - -// Dirs creates all directories needed to release veans. -func (Release) Dirs() error { - for _, d := range []string{"binaries", "release", "zip"} { - if err := os.MkdirAll(filepath.Join(releaseDist, d), 0o755); err != nil { - return err - } - } - return nil -} - -func releasePrepareXgo(_ context.Context) error { - if _, err := exec.LookPath("xgo"); err != nil { - fmt.Println("xgo not found, installing src.techknowlogick.com/xgo...") - if err := sh.RunV("go", "install", "src.techknowlogick.com/xgo@latest"); err != nil { - return fmt.Errorf("installing xgo: %w", err) - } - } - fmt.Println("Pulling latest xgo docker image...") - return sh.RunV("docker", "pull", "ghcr.io/techknowlogick/xgo:latest") -} - -func runXgo(ctx context.Context, targets string) error { - mg.Deps(releaseInitVars) - if err := releasePrepareXgo(ctx); err != nil { - return err - } - - extraLdflags := `-linkmode external -extldflags "-static" ` - // xgo's darwin builds can't use the static external linker. - if strings.HasPrefix(targets, "darwin") { - extraLdflags = "" - } - - outName := os.Getenv("XGO_OUT_NAME") - if outName == "" { - outName = "veans-" + releaseVersionString - } - - return sh.RunV("xgo", - "-dest", filepath.Join(releaseDist, "binaries"), - "-tags", releaseTags, - "-ldflags", extraLdflags+releaseLdflags, - "-targets", targets, - "-out", outName, - "./cmd/veans", - ) -} - -// Windows builds binaries for windows. Same target set as parent vikunja. -func (Release) Windows(ctx context.Context) error { - return runXgo(ctx, "windows/*") -} - -// Linux builds binaries for linux. Same target set as parent vikunja. -func (Release) Linux(ctx context.Context) error { - targets := []string{ - "linux/amd64", - "linux/arm-5", - "linux/arm-6", - "linux/arm-7", - "linux/arm64", - "linux/mips", - "linux/mipsle", - "linux/mips64", - "linux/mips64le", - "linux/riscv64", - } - return runXgo(ctx, strings.Join(targets, ",")) -} - -// Darwin builds binaries for macOS. Same minimum (10.15) as parent. -func (Release) Darwin(ctx context.Context) error { - return runXgo(ctx, "darwin-10.15/*") -} - -// Xgo cross-compiles a single os/arch[-variant] target. -func (Release) Xgo(ctx context.Context, target string) error { - parts := strings.Split(target, "/") - if len(parts) < 2 { - return fmt.Errorf("invalid target %q (expected os/arch[/variant])", target) - } - variant := "" - if len(parts) > 2 && parts[2] != "" { - variant = "-" + strings.ReplaceAll(parts[2], "v", "") - } - return runXgo(ctx, parts[0]+"/"+parts[1]+variant) -} - -// Compress runs upx -9 over each built binary that upx can actually handle. -// Skip list matches the parent vikunja magefile. -func (Release) Compress(_ context.Context) error { - var wg sync.WaitGroup - var ( - mu sync.Mutex - firstErr error - ) - record := func(err error) { - if err == nil { - return - } - mu.Lock() - if firstErr == nil { - firstErr = err - } - mu.Unlock() - } - - walkErr := filepath.Walk(filepath.Join(releaseDist, "binaries"), func(path string, info os.FileInfo, err error) error { - if err != nil || info.IsDir() { - return err - } - name := info.Name() - if !strings.Contains(name, "veans") { - return nil - } - if strings.Contains(name, "mips") || - strings.Contains(name, "s390x") || - strings.Contains(name, "riscv64") || - strings.Contains(name, "darwin") || - (strings.Contains(name, "windows") && strings.Contains(name, "arm64")) { - // upx can't compress these targets. - return nil - } - wg.Add(1) - go func(p string) { - defer wg.Done() - if err := sh.RunV("chmod", "+x", p); err != nil { - record(err) - return - } - record(sh.RunV("upx", "-9", p)) - }(path) - return nil - }) - if walkErr != nil { - return walkErr - } - wg.Wait() - return firstErr -} - -// Copy copies all built binaries to dist/release/ as the staging area for -// per-target bundles and nfpm. -func (Release) Copy() error { - return filepath.Walk(filepath.Join(releaseDist, "binaries"), func(path string, info os.FileInfo, err error) error { - if err != nil || info.IsDir() { - return err - } - if !strings.Contains(info.Name(), "veans") { - return nil - } - return copyFile(path, filepath.Join(releaseDist, "release", info.Name())) - }) -} - -// Check writes a sha256 file next to each binary in dist/release/. -func (Release) Check() error { - p := filepath.Join(releaseDist, "release") - return filepath.Walk(p, func(path string, info os.FileInfo, err error) error { - if err != nil || info.IsDir() { - return err - } - if strings.HasSuffix(info.Name(), ".sha256") { - return nil - } - sum, err := sha256File(path) - if err != nil { - return err - } - return os.WriteFile(path+".sha256", []byte(sum+" "+info.Name()+"\n"), 0o644) - }) -} - -// OsPackage creates one folder per binary in dist/release/, populated with -// the binary, its sha256, and the LICENSE so the bundle is self-contained. -func (Release) OsPackage() error { - p := filepath.Join(releaseDist, "release") - - // Snapshot first so we don't walk into the newly-created folders. - bins := map[string]os.FileInfo{} - if err := filepath.Walk(p, func(path string, info os.FileInfo, err error) error { - if err != nil || info.IsDir() { - return err - } - if strings.HasSuffix(info.Name(), ".sha256") { - return nil - } - bins[path] = info - return nil - }); err != nil { - return err - } - - licensePath, err := licenseSource() - if err != nil { - return err - } - - for binPath, info := range bins { - folder := filepath.Join(p, info.Name()+"-full") + string(os.PathSeparator) - if err := os.MkdirAll(folder, 0o755); err != nil { - return err - } - if err := moveFile(binPath+".sha256", filepath.Join(folder, info.Name()+".sha256")); err != nil { - return err - } - if err := moveFile(binPath, filepath.Join(folder, info.Name())); err != nil { - return err - } - if err := copyFile(licensePath, filepath.Join(folder, "LICENSE")); err != nil { - return err - } - } - return nil -} - -// Zip turns each per-target folder under dist/release/-full/ into -// dist/zip/-full.zip. Uses the system `zip` so we get the same on-wire -// format as the parent's release artifacts. -func (Release) Zip(ctx context.Context) error { - rootDir, err := os.Getwd() - if err != nil { - return err - } - p := filepath.Join(releaseDist, "release") - return filepath.Walk(p, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if !info.IsDir() || info.Name() == "release" { - return nil - } - fmt.Printf("Zipping %s...\n", info.Name()) - zipFile := filepath.Join(rootDir, releaseDist, "zip", info.Name()+".zip") - //nolint:gosec // mage build helper; arguments are derived from the local fs walk above. - c := exec.CommandContext(ctx, "zip", "-r", zipFile, ".", "-i", "*") - c.Dir = path - c.Stdout = os.Stdout - c.Stderr = os.Stderr - return c.Run() - }) -} - -// PrepareNFPMConfig templates ./nfpm.yaml in place, substituting , -// and the same way the parent magefile does. Set -// NFPM_ARCH to the nfpm arch name (amd64, arm64, arm7, 386) before calling. -// The substituted file is meant to be consumed by `nfpm pkg` immediately -// after; this is destructive and intentional (the CI checks the repo out -// fresh per job). -func (Release) PrepareNFPMConfig() error { - mg.Deps(releaseInitVars) - cfgPath := "./nfpm.yaml" - raw, err := os.ReadFile(cfgPath) - if err != nil { - return err - } - - var nfpmArch string - switch os.Getenv("NFPM_ARCH") { - case "arm64": - nfpmArch = "arm64" - case "arm7": - nfpmArch = "arm7" - case "386": - nfpmArch = "386" - default: - nfpmArch = "amd64" - } - - // nfpm resolves relative to its working directory. In CI the - // nfpm action runs from $GITHUB_WORKSPACE while the veans source already - // occupies ./veans, so the CI stages the binary at ./veans/veans-bin and - // passes NFPM_BIN_PATH=./veans/veans-bin. Outside CI the default works for - // a local `mage build && mage release:prepare-nfpm-config && nfpm pkg - // --config nfpm.yaml` from inside veans/. - binLocation := os.Getenv("NFPM_BIN_PATH") - if binLocation == "" { - binLocation = "./veans" - } - - fixed := strings.ReplaceAll(string(raw), "", releaseVersionNumber) - fixed = strings.ReplaceAll(fixed, "", nfpmArch) - fixed = strings.ReplaceAll(fixed, "", binLocation) - return os.WriteFile(cfgPath, []byte(fixed), 0o600) -} - -// ----------------------------------------------------------------------------- -// helpers - -func copyFile(src, dst string) error { - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - out, err := os.Create(dst) - if err != nil { - return err - } - defer out.Close() - if _, err := io.Copy(out, in); err != nil { - return err - } - si, err := os.Stat(src) - if err != nil { - return err - } - if err := os.Chmod(dst, si.Mode()); err != nil { - return err - } - return out.Close() -} - -func moveFile(src, dst string) error { - if err := copyFile(src, dst); err != nil { - return err - } - return os.Remove(src) -} - -func sha256File(path string) (string, error) { - f, err := os.Open(path) - if err != nil { - return "", err - } - defer f.Close() - h := sha256.New() - if _, err := io.Copy(h, f); err != nil { - return "", err - } - return fmt.Sprintf("%x", h.Sum(nil)), nil -} - -// licenseSource resolves the AGPLv3 LICENSE file. veans intentionally doesn't -// vendor its own copy — the parent repo's LICENSE applies to both. Look in -// ../LICENSE (the normal layout when running from veans/) and fall back to -// ./LICENSE for unusual checkouts. -func licenseSource() (string, error) { - for _, p := range []string{"../LICENSE", "./LICENSE"} { - if _, err := os.Stat(p); err == nil { - return p, nil - } - } - return "", fmt.Errorf("could not find LICENSE in ../ or ./") -} - // Aliases lets `mage test` resolve to `Test.All` (and the others) without // having to spell out the namespace. Mirrors the parent magefile's pattern. var Aliases = map[string]any{ - "test": Test.All, - "test:filter": Test.Filter, - "test:e2e": Test.E2E, - "lint": Lint.All, - "lint:fix": Lint.Fix, - "release": Release.Release, - "release:xgo": Release.Xgo, - "release:prepare-nfpm-config": Release.PrepareNFPMConfig, + "test": Test.All, + "test:filter": Test.Filter, + "test:e2e": Test.E2E, + "lint": Lint.All, + "lint:fix": Lint.Fix, }