gitmote / internal / runner / act.go · history · branches
6245 bytes · raw
package runner
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"strings"
)
// secretEnvPrefix namespaces CI secrets the dispatcher injects into the runner
// env (kept in sync with internal/ci's constant of the same name). The engine
// strips the prefix and forwards each as an act secret.
const secretEnvPrefix = "GITMOTE_CI_SECRET_"
// actPlatformsEnv, when set, is a comma-separated list of act -P platform
// mappings (e.g. "ubuntu-latest=-self-hosted"). The cloud runner image sets it
// to run steps directly in the Job container — Serverless Jobs have no Docker
// daemon for act to nest into. Unset (local dev, where a real daemon exists) →
// act's default nested-container behavior, unchanged.
const actPlatformsEnv = "GITMOTE_ACT_PLATFORMS"
// allowBuildsEnv opts a nested-mode runner into container image builds. In
// nested mode act bind-mounts the host Docker socket into every job container by
// default, so untrusted workflow code can reach the daemon — i.e. host root.
// Unless this is truthy (strconv.ParseBool) we pass `--container-daemon-socket -`
// to suppress that mount; set it only for trusted repos on a daemon-backed
// local/VPS host that must run `docker build`. In self-hosted mode there is no
// job container to mount into, so the knob is moot.
const allowBuildsEnv = "GITMOTE_CI_ALLOW_BUILDS"
// actDaemonSocketEnv overrides the daemon-side Docker socket path act mounts into
// each job container when builds are enabled. Defaults to defaultActDaemonSocket.
// The mount source must be the socket path the *daemon* sees, not DOCKER_HOST: for
// a VM-backed daemon (colima, Docker Desktop on macOS) DOCKER_HOST is a host path
// the in-VM daemon can't resolve, so letting act derive the mount from it fails
// with "operation not supported". Override only for an exotic daemon (e.g. rootless
// docker at $XDG_RUNTIME_DIR/docker.sock).
const actDaemonSocketEnv = "GITMOTE_ACT_DAEMON_SOCKET"
// defaultActDaemonSocket is the daemon's own socket path for native docker and for
// VM-backed daemons (colima, Docker Desktop) alike.
const defaultActDaemonSocket = "/var/run/docker.sock"
// ActEngine runs the repo's workflows with nektos/act — the locked engine
// (tasks/16-ci.md Stage 0 #1). act runs each job in a container via the local
// Docker/podman daemon and speaks GitHub-Actions YAML, so a workflow is
// byte-identical whether it lives in .gitmote/workflows (run here) or
// .github/workflows (run on the GitHub mirror — break-glass). It requires `act`
// on PATH and a reachable Docker daemon.
type ActEngine struct{}
// Run invokes act over workflowDir inside repoDir on the default (push) event,
// capturing combined stdout+stderr. act's non-zero exit means the workflow
// failed (passed=false); a failure to start act at all is an engine error.
//
// Injected CI secrets (GITMOTE_CI_SECRET_<NAME> in the env) are forwarded to act
// as `-s NAME`, which act reads from its environment (so values never touch the
// argv, and multiline values survive) and exposes to the workflow as
// `${{ secrets.NAME }}` — exactly as on GitHub.
func (ActEngine) Run(ctx context.Context, repoDir, workflowDir string, sink io.Writer) ([]byte, bool, error) {
if _, err := exec.LookPath("act"); err != nil {
return nil, false, fmt.Errorf("act not found on PATH — install nektos/act (https://github.com/nektos/act): %w", err)
}
args, extraEnv := actArgs(workflowDir, os.Environ())
actEnv := append(os.Environ(), extraEnv...)
cmd := exec.CommandContext(ctx, "act", args...)
cmd.Dir = repoDir
cmd.Env = actEnv
// Capture the full combined output for the durable log, and tee it to sink for
// the live tail (nil sink → capture only).
var buf bytes.Buffer
var out io.Writer = &buf
if sink != nil {
out = io.MultiWriter(&buf, sink)
}
cmd.Stdout = out
cmd.Stderr = out
err := cmd.Run()
if err == nil {
return buf.Bytes(), true, nil
}
var ee *exec.ExitError
if errors.As(err, &ee) {
// act ran and the workflow failed — a normal failed build, not an
// internal error.
return buf.Bytes(), false, nil
}
return buf.Bytes(), false, err
}
// actArgs builds act's arguments and the extra env for one invocation from the
// process environment. Injected secrets (GITMOTE_CI_SECRET_<NAME>) become
// `-s NAME` plus a bare NAME=value in extraEnv (act reads the value from its env,
// keeping it off the argv). GITMOTE_ACT_PLATFORMS becomes one `-P` per mapping.
// Pure, so it is unit-testable without act on PATH.
func actArgs(workflowDir string, environ []string) (args, extraEnv []string) {
args = []string{"--workflows", workflowDir}
var selfHosted, allowBuilds bool
daemonSocket := defaultActDaemonSocket
for _, kv := range environ {
name, val, _ := strings.Cut(kv, "=")
switch name {
case actPlatformsEnv:
for _, p := range strings.Split(val, ",") {
if p = strings.TrimSpace(p); p != "" {
args = append(args, "-P", p)
selfHosted = true
}
}
case allowBuildsEnv:
allowBuilds, _ = strconv.ParseBool(strings.TrimSpace(val))
case actDaemonSocketEnv:
if v := strings.TrimSpace(val); v != "" {
daemonSocket = v
}
default:
if secret, ok := strings.CutPrefix(name, secretEnvPrefix); ok && secret != "" {
extraEnv = append(extraEnv, secret+"="+val)
args = append(args, "-s", secret)
}
}
}
// How the job-container Docker socket is mounted:
switch {
case selfHosted:
// No nested job container to mount into — the step runs in the container
// directly, so the flag would be meaningless.
case !allowBuilds:
// Nested mode mounts the host Docker socket into each job container by
// default, handing untrusted workflow code the daemon (host root). Suppress
// it unless builds are explicitly opted in.
args = append(args, "--container-daemon-socket", "-")
default:
// Builds opted in: mount the daemon's own socket so `docker build` reaches
// it. Pin the daemon-side path rather than let act derive the mount source
// from DOCKER_HOST — a VM-backed daemon (colima, Docker Desktop on macOS)
// can't resolve that host path and the mount fails.
args = append(args, "--container-daemon-socket", daemonSocket)
}
return args, extraEnv
}