]> ruderich.org/simon Gitweb - punyci/punyci.git/commitdiff
First working version
authorSimon Ruderich <simon@ruderich.org>
Sun, 22 Feb 2026 13:34:46 +0000 (14:34 +0100)
committerSimon Ruderich <simon@ruderich.org>
Sun, 22 Feb 2026 13:34:46 +0000 (14:34 +0100)
12 files changed:
.gitignore [new file with mode: 0644]
.golangci.yml [new file with mode: 0644]
.punyci.toml [new file with mode: 0644]
Makefile [new file with mode: 0644]
README.adoc [new file with mode: 0644]
ci/run [new file with mode: 0755]
git.go [new file with mode: 0644]
go.mod [new file with mode: 0644]
go.sum [new file with mode: 0644]
job.go [new file with mode: 0644]
mail.go [new file with mode: 0644]
main.go [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..071e3f8
--- /dev/null
@@ -0,0 +1 @@
+/punyci
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644 (file)
index 0000000..8fd3586
--- /dev/null
@@ -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 (file)
index 0000000..b19845b
--- /dev/null
@@ -0,0 +1,3 @@
+[[Jobs]]
+Image = "golang:1.26"
+Script = "./ci/run"
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
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 (file)
index 0000000..357c11c
--- /dev/null
@@ -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 <<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/>.
diff --git a/ci/run b/ci/run
new file mode 100755 (executable)
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 (file)
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 (file)
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 (file)
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 (file)
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 (file)
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 (file)
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: