package runner
import (
"context"
"craftdesign.group/quarry/internal/outbound"
"errors"
"flag"
"fmt"
"net/url"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
)
// Main is the runner's command line, shared by the standalone
// quarry-runner program and `quarry runner`. prog names the command in
// usage and errors; version is printed by --version and at startup.
func Main(args []string, prog, version string) error {
fs := flag.NewFlagSet(prog, flag.ContinueOnError)
maxTasks := fs.Int("max-tasks", 2, "tasks run at once")
work := fs.String("work", "", "workspace directory (default: the user cache dir)")
tasks := fs.String("tasks", "", "task types to poll for, comma separated (default: the task names in ci/runner.json on trunk)")
mode := fs.String("checkout", "archive", `how a task gets its check-in: "archive" downloads the tree from the forge (needs nothing installed); "zit" clones it (needs zit, gives a real repository)`)
zitBin := fs.String("zit", "zit", `the zit binary, with --checkout zit`)
user := fs.String("user", "", "account to fetch a PRIVATE repository as; its sync token goes in QUARRY_RUNNER_PASSWORD")
showVersion := fs.Bool("version", false, "print the version and exit")
fs.Usage = func() {
fmt.Fprintf(fs.Output(), `usage: QUARRY_RUNNER_TOKEN=<worker token> %s [flags] https://<forge>/r/<repo>
Runs this repository's CI tasks on this machine. Each task fetches the
check-in under test, reads ci/runner.json AT that check-in, and runs the
command it names for the task's type:
{"tasks": {"test": ["go", "test", "./..."],
"lint": {"argv": ["ruff", "check", "."], "fail_on_output": true,
"env": {"PYTHONPATH": "src"}, "dir": "app"}}}
It runs whatever the check-in's manifest says. Point it only at a
repository whose writers you trust with this machine.
Mint the token in the repository's Settings → Worker tokens.
`, prog)
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
return err
}
if *showVersion {
fmt.Println(prog, version)
return nil
}
if fs.NArg() != 1 {
fs.Usage()
return errors.New("name the repository's URL")
}
repoURL := strings.TrimSuffix(fs.Arg(0), "/")
u, err := url.Parse(repoURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("%q is not a repository URL like https://forge.example/r/name", repoURL)
}
// The runner sends its token and fetches a private tree back: https,
// or http only to the forge on this same machine (#225).
if _, err := outbound.Check(repoURL); err != nil {
return fmt.Errorf("%q: %v", repoURL, err)
}
segs := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(segs) < 2 || segs[0] != "r" {
return fmt.Errorf("%q is not a repository URL like https://forge.example/r/name", repoURL)
}
repo := segs[len(segs)-1] // /r/<owner>/<repo> and /r/<repo> both name <repo>
token := strings.TrimSpace(os.Getenv("QUARRY_RUNNER_TOKEN"))
if token == "" {
return errors.New("set QUARRY_RUNNER_TOKEN to a worker token from the repository's Settings → Worker tokens")
}
password := os.Getenv("QUARRY_RUNNER_PASSWORD")
if password == "" {
password = os.Getenv("ZIT_PASSWORD") // the name `quarry runner` first shipped with
}
var checkout Checkout
switch *mode {
case "archive":
checkout = ArchiveCheckout{URL: repoURL, User: *user, Password: password}
case "zit":
checkout = ZitCheckout{Zit: *zitBin, URL: repoURL, User: *user}
default:
return fmt.Errorf(`--checkout is "archive" or "zit", not %q`, *mode)
}
dir := *work
if dir == "" {
cache, err := os.UserCacheDir()
if err != nil {
return err
}
dir = filepath.Join(cache, "quarry-runner", repo)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
names := splitList(*tasks)
if len(names) == 0 {
base := filepath.Join(dir, "trunk")
if err := checkout.Checkout(ctx, base, "trunk"); err != nil {
return fmt.Errorf("reading the task names from trunk: %w", err)
}
raw, err := os.ReadFile(filepath.Join(base, ManifestPath))
if err != nil {
return fmt.Errorf("trunk has no %s to learn task names from; add one or pass --tasks", ManifestPath)
}
m, err := ParseManifest(raw)
if err != nil {
return err
}
names = m.Names()
}
r, err := New(Config{
Bridge: u.Scheme + "://" + u.Host + "/r/" + url.PathEscape(repo) + "/ci/bridge",
Token: token,
Tasks: names,
MaxTasks: *maxTasks,
WorkDir: dir,
Checkout: checkout,
Version: version,
})
if err != nil {
return err
}
if err := r.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
return err
}
return nil
}
func splitList(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}