From: Simon Ruderich Date: Sun, 22 Feb 2026 13:34:46 +0000 (+0100) Subject: First working version X-Git-Url: https://ruderich.org/simon/gitweb/?a=commitdiff_plain;h=1f0c286c5e3c36711327a2c623c08bc5a5acd09d;p=punyci%2Fpunyci.git First working version --- diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..071e3f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/punyci diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..8fd3586 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,70 @@ +version: "2" + +linters: + default: none + enable: + # Enabled by default + - errcheck + - govet + - ineffassign + - staticcheck + - unused + # Additional checks + - bodyclose + - containedctx + - contextcheck + - copyloopvar + - durationcheck + - errname + - exhaustive + - exptostd + - gocheckcompilerdirectives + - gocritic + - iface + - importas + - nilerr + - nilnesserr + - nolintlint + - nonamedreturns + - nosprintfhostport + - predeclared + - reassign + - recvcheck + - rowserrcheck + - thelper + - tparallel + - unconvert + - usestdlibvars + - usetesting + - wastedassign + + settings: + exhaustive: + # "default" is good enough to be exhaustive + default-signifies-exhaustive: true + gocritic: + disabled-checks: + - exitAfterDefer + - ifElseChain + - singleCaseSwitch + staticcheck: + checks: + # Defaults + - "all" + - "-ST1000" + - "-ST1003" + - "-ST1016" + - "-ST1020" + - "-ST1021" + - "-ST1022" + # + - "-QF1001" + - "-QF1003" + - "-QF1003" + - "-QF1007" + usestdlibvars: + http-method: false + + +run: + timeout: 10m diff --git a/.punyci.toml b/.punyci.toml new file mode 100644 index 0000000..b19845b --- /dev/null +++ b/.punyci.toml @@ -0,0 +1,3 @@ +[[Jobs]] +Image = "golang:1.26" +Script = "./ci/run" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b8a5756 --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +all: + go build -race + go test -v + go fmt + go vet + +.PHONY: all diff --git a/README.adoc b/README.adoc new file mode 100644 index 0000000..357c11c --- /dev/null +++ b/README.adoc @@ -0,0 +1,104 @@ += README + +punyci is a smaller than tiny continuous integration (CI) tool for Git and +Podman. It runs as post-receive hook and executes the jobs with Podman in the +background. If a job fails it sends a mail to the current user. The container +is kept alive for an hour to debug issues "live" without needing multiple +runs. + +punyci is free software and licensed under GPL version 3 or later. + + +== Setup + +Compile `punyci` with `go build` or `make`. + +Setup `podman` for an unprivileged user. To receive mails `/usr/bin/sendmail` +needs to be installed (provided by e.g. Postfix, nullmailer, etc.). Create a +bare Git repository and call `punyci post-receive` from the post-receive hook. + +punyci can run on the same host or on a remote host via SSH. + + +== Usage + +Example: + + $ git init --bare repo.git + $ echo 'punyci post-receive' > repo.git/hooks/post-receive + $ chmod +x repo.git/hooks/post-receive + + # Optional, see below + $ git -C repo.git config receive.advertisePushOptions true + + $ git clone user@example.org:repo.git + $ cd repo + $ cat > .punyci.toml <. diff --git a/ci/run b/ci/run new file mode 100755 index 0000000..4831890 --- /dev/null +++ b/ci/run @@ -0,0 +1,21 @@ +#!/bin/sh + +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Simon Ruderich + +set -eu +set -x + + +# Go + +PATH=$HOME/go/bin:$PATH +export PATH + +make + +# Additional static checks only run in CI +go install golang.org/x/vuln/cmd/govulncheck@latest +govulncheck ./... +go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.10.1 +golangci-lint run diff --git a/git.go b/git.go new file mode 100644 index 0000000..742b0e1 --- /dev/null +++ b/git.go @@ -0,0 +1,155 @@ +// Interact with Git repositories + +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Simon Ruderich + +package main + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strconv" + "strings" + + "github.com/BurntSushi/toml" +) + +func HookReadRef() (string, string, error) { + // "post-receive" hooks get list of updated references on stdin + stdin, err := io.ReadAll(os.Stdin) + if err != nil { + return "", "", err + } + + var oids, names []string + for line := range bytes.Lines(stdin) { + fields := strings.Fields(string(line)) + if len(fields) != 3 { + return "", "", fmt.Errorf("invalid ref input %q", line) + } + oids = append(oids, fields[1]) + names = append(names, fields[2]) + } + + if len(names) != 1 { + return "", "", fmt.Errorf("only one ref supported, got %q", names) + } + branch, ok := strings.CutPrefix(names[0], "refs/heads/") + if !ok { + return "", "", fmt.Errorf("only branch ref supported, got %q", names) + } + + oid := oids[0] + // TODO: support sha256 + if oid == "0000000000000000000000000000000000000000" { + oid = "" // branch deleted + } + + // Also return the oid because the ref in the repo might change after + // receiving it from "post-receive". + return oid, branch, nil +} + +func HookGetPushOptions() []string { + x := os.Getenv("GIT_PUSH_OPTION_COUNT") + if x == "" { + return nil + } + n, err := strconv.Atoi(x) + if err != nil { + return nil + } + + var res []string + for i := range n { + res = append(res, os.Getenv("GIT_PUSH_OPTION_"+strconv.Itoa(i))) + } + return res +} + +// RepoGetConfig reads the punyci configuration from the git repo in the +// current working directory. +func RepoGetConfig(ref string) (*Config, error) { + const configName = ".punyci.toml" + + cmd := exec.Command("git", "cat-file", "blob", "--", ref+":"+configName) + data, err := cmd.Output() + if err != nil { + _, ok := errors.AsType[*exec.ExitError](err) + if ok { + // Exit code != 0 means the config file does not exist + return nil, nil + } + // Other errors must be reported + return nil, fmt.Errorf("failed to get config from repo: %v: %v", + cmd, err) + } + + cfg, err := parseConfig(string(data)) + if err != nil { + return nil, fmt.Errorf("%s: %v", configName, err) + } + return cfg, nil +} + +func parseConfig(data string) (*Config, error) { + var cfg Config + md, err := toml.Decode(data, &cfg) + if err != nil { + return nil, fmt.Errorf("invalid TOML: %v", err) + } + undecoded := md.Undecoded() + if len(undecoded) != 0 { + return nil, fmt.Errorf("invalid fields used: %v", undecoded) + } + return &cfg, nil +} + +// RepoClone clones a git repository and checkouts branch at the revision oid. +func RepoClone(src, dst, branch, oid string) error { + cmd := exec.Command("git", "clone", "--quiet", "--depth=1", + "--branch="+branch, "--", "file://"+src, dst) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err := cmd.Run() + if err != nil { + return fmt.Errorf("%v failed: %v", cmd, err) + } + + var env []string + for _, x := range os.Environ() { + // Must remove GIT_DIR which is set by the "post-receive" hook or + // `fetch` fails. Remove all git variables to be safe. + if strings.HasPrefix(x, "GIT_") { + continue + } + env = append(env, x) + } + + // Workaround clone's --revision which is only available in git >= 2.49 + cmd = exec.Command("git", "-C", dst, "fetch", "--quiet", + "--", "origin", oid) + cmd.Env = env + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err = cmd.Run() + if err != nil { + return fmt.Errorf("%v failed: %v", cmd, err) + } + cmd = exec.Command("git", "-C", dst, "reset", "--quiet", "--hard", oid) + cmd.Env = env + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err = cmd.Run() + if err != nil { + return fmt.Errorf("%v failed: %v", cmd, err) + } + + return nil +} + +// vi: set noet ts=4 sw=4 sts=4: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..34fd1e0 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module punyci + +go 1.26.0 + +require github.com/BurntSushi/toml v1.5.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ff7fd09 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= diff --git a/job.go b/job.go new file mode 100644 index 0000000..32da08d --- /dev/null +++ b/job.go @@ -0,0 +1,149 @@ +// Run CI jobs using podman + +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Simon Ruderich + +package main + +import ( + "bytes" + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" +) + +func RunJob(wg *sync.WaitGroup, tmp string, info Info, + id int, image, script string, + mail *bool, outres *[]byte, uuidres *string) error { + + cwd, err := os.Getwd() + if err != nil { + return err + } + repoPath := filepath.Join(tmp, strconv.Itoa(id)) + + err = RepoClone(cwd, repoPath, info.Branch, info.Oid) + if err != nil { + return fmt.Errorf("cloning %q/%q to %q failed: %v", + info.Branch, info.Oid, repoPath, err) + } + + scriptPath := repoPath + ".script" + err = os.WriteFile(scriptPath, []byte(script), 0700) + if err != nil { + return err + } + + // Start container but do not run /script yet. We need a way to a) sleep + // in case of an error so the user can analyze the failure and b) detect + // when /script terminates. The simplest solution is to run /script using + // exec while the container just sleeps forever. + cmd := exec.Command("podman", "run", "--rm", "--detach", + "--pull", "newer", + "--log-driver", "none", // don't log to journal + "--volume", repoPath+":/punyci-repo", + "--volume", scriptPath+":/punyci-script:ro", + "--entrypoint", "sh", // overwrite if set + "--", + image, + "-c", "while :; do sleep 86400; done", // sleep forever + ) + out, err := cmd.Output() + if err != nil { + return err + } + uuid := strings.TrimSpace(string(out)) + + cmd = exec.Command("podman", "exec", "--", uuid, + "sh", "-c", "cd /punyci-repo && /punyci-script") + + // Wait in the background until container is terminated or until timeout + triggerWait := func() { + timeout := 3600 + + log.Printf("container %s: waiting until timeout of %ds", + uuid, timeout) + wg.Go(func() { + cmd := exec.Command("podman", "exec", "--", uuid, + "sh", "-c", "sleep "+strconv.Itoa(timeout)) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err := cmd.Run() + if err != nil { + log.Print(err) + } + log.Printf("container %s: killing", uuid) + // And then kill the container + cmd = exec.Command("podman", "kill", "--", uuid) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err = cmd.Run() + if err != nil { + log.Print(err) + } + }) + } + + // On error the container is kept running so the user can inspect it. It + // will automatically terminate after a timeout. + if info.Wait { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err := cmd.Run() + if err != nil { + triggerWait() + // Output already shown, no need to report output + return fmt.Errorf("script failed in container %s: %v", uuid, err) + } + } else { + logPath := "punyci-log-" + strconv.Itoa(id) + failedPath := "punyci-failed-" + strconv.Itoa(id) + + f, err := os.Create(logPath) + if err != nil { + return err + } + defer f.Close() //nolint:errcheck // ignoring errors okay for log + + var buf bytes.Buffer + w := io.MultiWriter(&buf, f) + + cmd.Stdout = w + cmd.Stderr = w + err = cmd.Run() + if err != nil { + triggerWait() + + // Mark last CI run as failed + _ = os.WriteFile(failedPath, nil, 0644) + + *mail = true // request fail mail + *outres = buf.Bytes() + *uuidres = uuid + return err + } + + _, err = os.Stat(failedPath) + if err == nil { + _ = os.Remove(failedPath) + *mail = true // request fix mail + *outres = buf.Bytes() + } + } + + cmd = exec.Command("podman", "kill", "--", uuid) + err = cmd.Run() + if err != nil { + return err + } + + return nil +} + +// vi: set noet ts=4 sw=4 sts=4: diff --git a/mail.go b/mail.go new file mode 100644 index 0000000..23f1fce --- /dev/null +++ b/mail.go @@ -0,0 +1,65 @@ +// Send emails + +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Simon Ruderich + +package main + +import ( + "bytes" + "encoding/base64" + "fmt" + "os/exec" + "strings" +) + +func SendFailMail(to, name, image, script string, + err error, output []byte, container string) error { + + subject := fmt.Sprintf("punyci: %s FAILED", name) + var msg bytes.Buffer + fmt.Fprintf(&msg, "Attach to the failed container for one hour: %s\n", + container) + fmt.Fprintf(&msg, "\n") + fmt.Fprintf(&msg, "Image: %s\n", image) + fmt.Fprintf(&msg, "Error: %s\n", err) + fmt.Fprintf(&msg, "\n") + fmt.Fprintf(&msg, "Script:\n%s\n", strings.TrimSpace(script)) + fmt.Fprintf(&msg, "\n") + fmt.Fprintf(&msg, "Output:\n%s\n", output) + return SendMail(to, subject, msg.String()) +} + +func SendFixMail(to, name, image, script string, output []byte) error { + subject := fmt.Sprintf("punyci: %s FIXED", name) + var msg bytes.Buffer + fmt.Fprintf(&msg, "Image: %s\n", image) + fmt.Fprintf(&msg, "\n") + fmt.Fprintf(&msg, "Script:\n%s\n", strings.TrimSpace(script)) + fmt.Fprintf(&msg, "\n") + fmt.Fprintf(&msg, "Output:\n%s\n", output) + return SendMail(to, subject, msg.String()) +} + +func SendMail(to, subject, msg string) error { + var x bytes.Buffer + fmt.Fprintf(&x, "To: %s\n", to) + fmt.Fprintf(&x, "Subject: =?UTF-8?B?%s?=\n", + base64.StdEncoding.EncodeToString([]byte(subject))) + fmt.Fprintf(&x, "Content-Type: text/plain; charset=UTF-8\n") + fmt.Fprintf(&x, "\n") + fmt.Fprintf(&x, "%s", msg) + + cmd := exec.Command("/usr/sbin/sendmail", + "-i", // don't treat lines starting with . specially + "-t", // extract recipients from message headers + ) + cmd.Stdin = &x + err := cmd.Run() + if err != nil { + return err + } + return nil +} + +// vi: set noet ts=4 sw=4 sts=4: diff --git a/main.go b/main.go new file mode 100644 index 0000000..bf61447 --- /dev/null +++ b/main.go @@ -0,0 +1,207 @@ +// punyci: a less than tiny CI "pipeline" which runs jobs as podman containers + +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Simon Ruderich + +package main + +import ( + "bytes" + "encoding/json" + "io" + "log" + "os" + "os/exec" + "os/user" + "path/filepath" + "slices" + "strings" + "sync" +) + +type Config struct { + Jobs []struct { + Image string + Script string + } +} + +func main() { + usage := func() { + log.SetFlags(0) + log.Fatalf("usage: %s post-receive", os.Args[0]) + } + if len(os.Args) != 2 { + usage() + } + + switch os.Args[1] { + case "post-receive": + err := postReceive() + if err != nil { + log.Fatal(err) + } + case "internal-post-receive": + err := internalPostReceive() + if err != nil { + log.Fatal(err) + } + default: + usage() + } +} + +type Info struct { + Config Config + Wait bool + User string + Oid string + Branch string +} + +func postReceive() error { + oid, branch, err := HookReadRef() + if err != nil { + return err + } + // Branch was deleted + if oid == "" { + return nil + } + pushOptions := HookGetPushOptions() + + cfg, err := RepoGetConfig(oid) + if err != nil { + return err + } + // CI not configured + if cfg == nil || len(cfg.Jobs) == 0 { + return nil + } + + // Add shebang to script if not present + for i, job := range cfg.Jobs { + if !strings.HasPrefix(job.Script, "#!") { + cfg.Jobs[i].Script = "#!/bin/sh\n" + job.Script + } + } + + user, err := user.Current() + if err != nil { + return err + } + + // Pass data to "fork" via stdin + info := Info{ + Config: *cfg, + Wait: slices.Contains(pushOptions, "wait"), + User: user.Username, + Oid: oid, + Branch: branch, + } + x, err := json.Marshal(info) + if err != nil { + return err + } + + // Continue in the background ("fork"), unless Wait was set + cmd := exec.Command(os.Args[0], "internal-post-receive") + cmd.Stdin = bytes.NewReader(x) + if info.Wait { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } + err = cmd.Start() + if err != nil { + return err + } + + if info.Wait { + log.Printf("punyci: running %d jobs in the foreground", len(cfg.Jobs)) + err := cmd.Wait() + if err != nil { + return err + } + } else { + log.Printf("punyci: queued %d jobs", len(cfg.Jobs)) + } + + return nil +} + +func internalPostReceive() error { + x, err := io.ReadAll(os.Stdin) + if err != nil { + return err + } + var info Info + err = json.Unmarshal(x, &info) + if err != nil { + return err + } + + cwd, err := os.Getwd() + if err != nil { + return err + } + name := filepath.Base(cwd) + + tmp, err := os.MkdirTemp("", "punyci-") + if err != nil { + return err + } + + var failed bool + var wg sync.WaitGroup + for i, job := range info.Config.Jobs { + x := job.Script + if len(x) > 20 { + x = x[:20] + "[...]" + } + log.Printf("punyci: running job %d with image %q and script %q", + i, job.Image, x) + + var mail bool + var out []byte + var uuid string + err := RunJob(&wg, tmp, info, i, job.Image, job.Script, + &mail, &out, &uuid) + if err == nil { + if mail { + // Fixed a previously failed job, send email + err := SendFixMail(info.User, name, job.Image, job.Script, + out) + if err != nil { + log.Print(err) + } + } + } else { + failed = true + if mail { + // Failed job, send mail + err := SendFailMail(info.User, name, job.Image, job.Script, + err, out, uuid) + if err != nil { + log.Print(err) + } + } else { + log.Print(err) + } + } + } + + if failed { + log.Printf("punyci: waiting for failed containers, " + + "kill them manually to continue") + wg.Wait() + } + + err = os.RemoveAll(tmp) + if err != nil { + return err + } + + return nil +} + +// vi: set noet ts=4 sw=4 sts=4: