diff --git a/Dockerfile.runner b/Dockerfile.runner
new file mode 100644
index 0000000..6e9ebcd
--- /dev/null
+++ b/Dockerfile.runner
@@ -0,0 +1,36 @@
+# CI runner image: gitmote-runner + nektos/act, run as a Scaleway Serverless Job
+# (one definition serves all repos; env injected per job at trigger, per stage 3).
+#
+# act runs in SELF-HOSTED mode here (GITMOTE_ACT_PLATFORMS below): Serverless Jobs
+# have no Docker daemon, so act executes the workflow steps directly in THIS
+# container rather than nesting a per-job container. The Job is a fresh, ephemeral,
+# scale-to-zero container, so it is the sandbox. Tools workflows need (git, node,
+# bash) must therefore live in this image.
+
+# syntax=docker/dockerfile:1
+FROM golang:1.26-bookworm AS build
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/gitmote-runner ./cmd/gitmote-runner
+
+FROM debian:bookworm-slim
+ARG ACT_VERSION=0.2.80
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends git ca-certificates curl bash tar \
+ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
+ && apt-get install -y --no-install-recommends nodejs \
+ && curl -fsSL "https://github.com/nektos/act/releases/download/v${ACT_VERSION}/act_Linux_x86_64.tar.gz" \
+ | tar -xz -C /usr/local/bin act \
+ && apt-get purge -y curl \
+ && apt-get autoremove -y \
+ && rm -rf /var/lib/apt/lists/* \
+ && act --version && node --version && git --version
+
+COPY --from=build /out/gitmote-runner /usr/local/bin/gitmote-runner
+
+# Self-hosted execution for the common ubuntu labels — no Docker-in-Docker.
+ENV GITMOTE_ACT_PLATFORMS="ubuntu-latest=-self-hosted,ubuntu-22.04=-self-hosted,ubuntu-24.04=-self-hosted"
+
+ENTRYPOINT ["gitmote-runner"]
diff --git a/internal/runner/act.go b/internal/runner/act.go
index 4a869ee..dfa56b6 100644
--- a/internal/runner/act.go
+++ b/internal/runner/act.go
@@ -15,6 +15,13 @@ import (
// 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"
+
// ActEngine runs .github/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, so one definition runs both self-hosted and on the
@@ -34,16 +41,8 @@ func (ActEngine) Run(ctx context.Context, repoDir, workflowDir string) ([]byte,
return nil, false, fmt.Errorf("act not found on PATH — install nektos/act (https://github.com/nektos/act): %w", err)
}
- args := []string{"--workflows", workflowDir}
- actEnv := os.Environ()
- for _, kv := range os.Environ() {
- name, val, _ := strings.Cut(kv, "=")
- if secret, ok := strings.CutPrefix(name, secretEnvPrefix); ok && secret != "" {
- // Re-export under the bare name so `act -s NAME` finds it in the env.
- actEnv = append(actEnv, secret+"="+val)
- args = append(args, "-s", secret)
- }
- }
+ args, extraEnv := actArgs(workflowDir, os.Environ())
+ actEnv := append(os.Environ(), extraEnv...)
cmd := exec.CommandContext(ctx, "act", args...)
cmd.Dir = repoDir
@@ -64,3 +63,29 @@ func (ActEngine) Run(ctx context.Context, repoDir, workflowDir string) ([]byte,
}
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}
+ for _, kv := range environ {
+ name, val, _ := strings.Cut(kv, "=")
+ switch {
+ case name == actPlatformsEnv:
+ for _, p := range strings.Split(val, ",") {
+ if p = strings.TrimSpace(p); p != "" {
+ args = append(args, "-P", p)
+ }
+ }
+ default:
+ if secret, ok := strings.CutPrefix(name, secretEnvPrefix); ok && secret != "" {
+ extraEnv = append(extraEnv, secret+"="+val)
+ args = append(args, "-s", secret)
+ }
+ }
+ }
+ return args, extraEnv
+}
diff --git a/internal/runner/act_test.go b/internal/runner/act_test.go
new file mode 100644
index 0000000..3538eae
--- /dev/null
+++ b/internal/runner/act_test.go
@@ -0,0 +1,56 @@
+package runner
+
+import (
+ "slices"
+ "strings"
+ "testing"
+)
+
+func TestActArgsForwardsSecrets(t *testing.T) {
+ args, extra := actArgs(".github/workflows", []string{
+ "PATH=/usr/bin",
+ "GITMOTE_CI_SECRET_API_TOKEN=s3cr3t",
+ "GITMOTE_URL=http://x", // not a secret; must be ignored
+ })
+ if !slices.Contains(extra, "API_TOKEN=s3cr3t") {
+ t.Errorf("extraEnv = %v, want the bare-named secret re-exported", extra)
+ }
+ if !hasFlag(args, "-s", "API_TOKEN") {
+ t.Errorf("args = %v, want -s API_TOKEN", args)
+ }
+ // The secret value must not appear on the argv.
+ if slices.ContainsFunc(args, func(s string) bool { return strings.Contains(s, "s3cr3t") }) {
+ t.Errorf("secret value leaked onto argv: %v", args)
+ }
+}
+
+func TestActArgsPlatforms(t *testing.T) {
+ args, _ := actArgs(".github/workflows", []string{
+ "GITMOTE_ACT_PLATFORMS=ubuntu-latest=-self-hosted, ubuntu-22.04=-self-hosted",
+ })
+ if !hasFlag(args, "-P", "ubuntu-latest=-self-hosted") ||
+ !hasFlag(args, "-P", "ubuntu-22.04=-self-hosted") {
+ t.Errorf("args = %v, want a -P per platform mapping (trimmed)", args)
+ }
+}
+
+func TestActArgsDefaultsNoOverrides(t *testing.T) {
+ args, extra := actArgs(".github/workflows", []string{"HOME=/root"})
+ if len(extra) != 0 {
+ t.Errorf("extraEnv = %v, want none", extra)
+ }
+ // Just the workflows flag — no -s, no -P (local dev: act's default nesting).
+ if hasFlag(args, "-P", "") || slices.Contains(args, "-s") {
+ t.Errorf("args = %v, want only --workflows with no overrides", args)
+ }
+}
+
+// hasFlag reports whether args contains flag immediately followed by value.
+func hasFlag(args []string, flag, value string) bool {
+ for i := 0; i+1 < len(args); i++ {
+ if args[i] == flag && args[i+1] == value {
+ return true
+ }
+ }
+ return false
+}