vikunja/veans/magefile.go

593 lines
17 KiB
Go

// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//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.
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"
)
// Build compiles the veans binary into ./veans (or ./veans.exe on Windows).
func Build() error {
out := "./veans"
if runtime.GOOS == "windows" {
out = "./veans.exe"
}
return sh.RunV("go", "build", "-o", out, "./cmd/veans")
}
// Clean removes built artifacts.
func Clean() error {
for _, p := range []string{"./veans", "./veans.exe", "./" + releaseDist} {
if _, err := os.Stat(p); err == nil {
if err := os.RemoveAll(p); err != nil {
return err
}
}
}
return nil
}
// Fmt runs goimports across the module.
func Fmt() error {
return sh.RunV("go", "fmt", "./...")
}
// Test namespace.
type Test mg.Namespace
// All runs unit tests across the module. Passes `-short` so the e2e
// package self-skips via its TestMain — the parent monorepo's
// pkg/webtests follows the same convention.
func (Test) All() error {
return sh.RunV("go", "test", "-short", "./...")
}
// Filter runs `go test -short -run <expr> ./...` — pass the expression as
// an argument. `-short` is included so e2e doesn't run accidentally; use
// `mage test:e2e` for those.
func (Test) Filter(expr string) error {
if expr == "" {
return fmt.Errorf("test:filter requires a regexp argument")
}
return sh.RunV("go", "test", "-short", "-run", expr, "./...")
}
// E2E runs the e2e suite without `-short` so TestMain lets it through.
// Requires VEANS_E2E_API_URL to point at a running Vikunja instance and
// either VEANS_E2E_TESTING_TOKEN (matching the API's VIKUNJA_SERVICE_TESTINGTOKEN
// — the harness will seed its own admin via /api/v1/test/users) or
// VEANS_E2E_ADMIN_TOKEN (a pre-existing JWT for the admin to use as-is).
//
// Set VEANS_E2E_SKIP_BUILD=true to reuse a previously-built binary.
func (Test) E2E() error {
if os.Getenv("VEANS_E2E_API_URL") == "" {
return fmt.Errorf("VEANS_E2E_API_URL is not set — start a Vikunja instance and export the URL")
}
if os.Getenv("VEANS_E2E_ADMIN_TOKEN") == "" && os.Getenv("VEANS_E2E_TESTING_TOKEN") == "" {
return fmt.Errorf("set VEANS_E2E_ADMIN_TOKEN, or VEANS_E2E_TESTING_TOKEN (matching the API's VIKUNJA_SERVICE_TESTINGTOKEN) so the suite can seed its own admin")
}
if os.Getenv("VEANS_E2E_SKIP_BUILD") == "" {
if err := Build(); err != nil {
return err
}
}
abs, err := filepath.Abs("./veans")
if err != nil {
return err
}
return sh.RunWithV(map[string]string{"VEANS_BINARY": abs}, "go", "test", "-count=1", "./e2e/...")
}
// Lint namespace.
type Lint mg.Namespace
// All runs golangci-lint over the module.
func (Lint) All() error {
if _, err := exec.LookPath("golangci-lint"); err != nil {
return fmt.Errorf("golangci-lint not installed: %w", err)
}
return sh.RunV("golangci-lint", "run", "./...")
}
// Fix runs golangci-lint with --fix.
func (Lint) Fix() error {
if _, err := exec.LookPath("golangci-lint"); err != nil {
return fmt.Errorf("golangci-lint not installed: %w", err)
}
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
// `<veans>/dist/`. The CI workflow uploads dist/zip/* to S3 /veans/<ver>/ 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/<name>-full/ into
// dist/zip/<name>-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 <version>,
// <arch> and <binlocation> 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 <binlocation> 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), "<version>", releaseVersionNumber)
fixed = strings.ReplaceAll(fixed, "<arch>", nfpmArch)
fixed = strings.ReplaceAll(fixed, "<binlocation>", 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,
}