--- /dev/null
+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
--- /dev/null
+[[Jobs]]
+Image = "golang:1.26"
+Script = "./ci/run"
--- /dev/null
+all:
+ go build -race
+ go test -v
+ go fmt
+ go vet
+
+.PHONY: all
--- /dev/null
+= 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 <<EOF
+ [[Jobs]]
+ Image = "debian:trixie"
+ Script = "exit 1"
+ EOF
+ $ git add .punyci.toml
+ $ git commit -m punyci
+ $ git push
+ [...]
+ remote: 2026/02/22 14:24:52 punyci: queued 1 jobs
+
+This will create an email reporting the failure of the job. It's send to the
+current user the CI is running as (adapt with `~/.forward` or similar). The
+container continues to run for about an hour. To debug the issue simply run
+`podman exec -it $uuid-from-email bash` on the host where the CI is running.
+
+If a job succeeds then no mail is generated. The only exception is if the
+previous run of the job failed. Then a "fixed" mail is generated. Failing jobs
+are marked with an empty file `punyci-failed-$job` in the bare repository.
+
+The build log of the last job is stored in `punyci-log-$job` in the bare
+repository. It's written "live" during the run and can be used with `tail -f`
+to watch the output during the run.
+
+To run the CI in the foreground use `git push -o wait`. This needs the option
+`receive.advertisePushOptions = true` in the remote repository. If a job fails
+the command will hang until the timeout. Simply press Ctrl-C or kill the
+containers manually.
+
+
+== Configuration
+
+punyci is configured through `.punyci.toml` in the top-level of the
+repository.
+
+ [[Jobs]]
+ Image = "golang:1.26"
+ Script = "./ci/run"
+
+ [[Jobs]]
+ Image = "golang:1.25"
+ Script = "./ci/run"
+
+An unlimited number of jobs can be configured. They are run in sequence. A
+failed job doesn't stop the following jobs from starting.
+
+`Script` can be an arbitrary script (multiple lines with TOML's `"""`
+strings). If no shebang is present then `#!/bin/sh` is prepended. Otherwise
+the existing shebang is used.
+
+
+== Licenses
+
+punyci is licensed under GPL 3 or later.
+
+Copyright (C) 2026 Simon Ruderich
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU 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 General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see <http://www.gnu.org/licenses/>.
--- /dev/null
+#!/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
--- /dev/null
+// 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:
--- /dev/null
+module punyci
+
+go 1.26.0
+
+require github.com/BurntSushi/toml v1.5.0
--- /dev/null
+github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
+github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
--- /dev/null
+// 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:
--- /dev/null
+// 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:
--- /dev/null
+// 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: