diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..347a19c
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,59 @@
+name: CI
+
+# Runs on every push to the mainline branches and on all pull requests. Tag
+# pushes are handled by release.yml, so they are excluded here.
+on:
+ push:
+ branches: [main, dev]
+ pull_request:
+ branches: [main, dev]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ name: Build & Test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: go vet
+ run: go vet ./...
+
+ - name: Test
+ run: go test -race ./...
+
+ no-docker-sdk:
+ # The runner shells out to the docker / pack / buildctl CLIs by design; it
+ # deliberately links NO Docker SDK. Linking one re-couples Miabi's module
+ # graph to github.com/docker/docker (deprecated, no v29) or moby/moby — the
+ # exact coupling the platform's SDK migration removes. See the "No Docker
+ # SDK" section of README.md for the full rationale.
+ name: No Docker SDK
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: the runner must not link a Docker SDK
+ run: |
+ if go list -m all | grep -qE 'github.com/docker/docker|github.com/moby/moby'; then
+ echo "::error::The runner shells out to the docker CLI by design."
+ echo "::error::Linking a Docker SDK re-couples Miabi's module graph to it."
+ echo "::error::See the 'No Docker SDK' section of README.md before removing this guard."
+ go list -m all | grep -E 'github.com/docker/docker|github.com/moby/moby'
+ exit 1
+ fi
+ echo "OK: no Docker SDK in the module graph."
diff --git a/README.md b/README.md
index d28924f..f07199a 100644
--- a/README.md
+++ b/README.md
@@ -19,13 +19,29 @@ Register a runner in the Miabi UI (**Settings → Runners → Add runner**, or
```sh
docker run -d --name miabi-runner \
- -e MIABI_CONTROL_URL=https://panel.example.com \
+ -e MIABI_CONTROL_URL=https://miabi.example.com \
-e MIABI_RUNNER_TOKEN=mbr_xxxxxxxx \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ -v /srv/miabi/builds:/srv/miabi/builds \
+ -e MIABI_RUNNER_BUILDS_DIR=/srv/miabi/builds \
miabi/runner:latest
```
+The default `docker` backend builds and runs steps against a Docker daemon, so
+the container needs the **host Docker socket** bind above (this is the runner's
+*own* daemon — it is never exposed to the control plane). The builds-dir volume
+is mounted at the same path inside and out so the per-step `-v` mounts resolve on
+the host daemon (see `MIABI_RUNNER_BUILDS_DIR` below). Using the rootless
+`buildkit` backend (`-e MIABI_RUNNER_BUILDER=buildkit`) needs neither.
+
Or as a binary: `MIABI_CONTROL_URL=… MIABI_RUNNER_TOKEN=… ./miabi-runner`.
+### CI/CD pipelines in Miabi
+
+
+
+
+
## Configuration (environment)
| Variable | Required | Meaning |
@@ -50,4 +66,8 @@ root `Dockerfile` → Dockerfile build, otherwise buildpacks):
extra buildpacks/build-env come from the job.
The runner reports its OS/arch/version to the control plane on connect (used for
-label/arch job scheduling). Licensed under Apache-2.0.
+label/arch job scheduling).
+
+
+
+Licensed under Apache-2.0.
diff --git a/executor_buildkit.go b/executor_buildkit.go
index 06e78b5..5445f6a 100644
--- a/executor_buildkit.go
+++ b/executor_buildkit.go
@@ -112,19 +112,29 @@ func (r *buildkitJobRun) build(ctx context.Context, step proto.StepSpec, log fun
ref := r.job.Repository + ":" + buildTag(r.job)
meta := filepath.Join(r.workdir, ".miabi-build-metadata.json")
+ cdir, err := contextDir(r.workdir, step.Build)
+ if err != nil {
+ return StepResult{}, err
+ }
buildArgs := []string{
"build",
"--frontend", "dockerfile.v0",
- "--local", "context=" + r.workdir,
+ "--local", "context=" + cdir,
+ // The dockerfile local stays the source root, and `filename` is resolved
+ // against it — so a Dockerfile outside the context still builds, matching
+ // `docker build -f` semantics rather than BuildKit's default of expecting
+ // the Dockerfile inside the context.
"--local", "dockerfile=" + r.workdir,
"--opt", "filename=" + dockerfilePath(step.Build),
"--output", fmt.Sprintf("type=image,name=%s,push=true", ref),
"--metadata-file", meta,
}
+ // buildctl spells a Dockerfile ARG as `--opt build-arg:KEY=VALUE`.
+ buildArgs = append(buildArgs, buildArgFlags(step.Build, "--opt", "build-arg:")...)
// Point BuildKit at the per-job docker config for its push credential.
name, args := r.buildctlCmd(buildArgs)
- log("building " + ref + " (rootless buildkit)")
+ log("building " + ref + " (rootless buildkit, context " + contextLabel(r.workdir, cdir) + ")")
if code, err := r.e.cmd.run(ctx, r.workdir, log, name, args...); err != nil {
return StepResult{}, fmt.Errorf("buildctl: %w", err)
} else if code != 0 {
diff --git a/executor_common.go b/executor_common.go
index 644bada..5987b2a 100644
--- a/executor_common.go
+++ b/executor_common.go
@@ -68,6 +68,73 @@ func dockerfilePath(cfg *proto.BuildConfig) string {
return "Dockerfile"
}
+// contextDir resolves a build step's context to an absolute path under workdir.
+//
+// The control plane already rejects absolute paths and `..` escapes, but this is
+// the process that actually runs the build: a runner is shared across a
+// workspace's pipelines, and a pipeline file is editable by anyone who can push a
+// branch. Re-checking here means a control plane that ever stops validating —
+// or a runner driven by something else — cannot be talked into mounting the
+// runner's own filesystem as a build context.
+func contextDir(workdir string, cfg *proto.BuildConfig) (string, error) {
+ if cfg == nil || strings.TrimSpace(cfg.Context) == "" {
+ return workdir, nil
+ }
+ rel := strings.TrimSpace(cfg.Context)
+ if filepath.IsAbs(rel) {
+ return "", fmt.Errorf("build context %q must be relative to the repository root", rel)
+ }
+ abs := filepath.Join(workdir, rel)
+ // Join cleans the result, so a contained path keeps workdir as its prefix.
+ if abs != workdir && !strings.HasPrefix(abs, workdir+string(filepath.Separator)) {
+ return "", fmt.Errorf("build context %q escapes the repository", rel)
+ }
+ info, err := os.Stat(abs)
+ if err != nil {
+ return "", fmt.Errorf("build context %q not found in the repository", rel)
+ }
+ if !info.IsDir() {
+ return "", fmt.Errorf("build context %q is not a directory", rel)
+ }
+ return abs, nil
+}
+
+// sortedKeys returns a map's keys in order, so every argv this package builds is
+// deterministic and therefore testable.
+func sortedKeys(m map[string]string) []string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
+
+// buildArgFlags renders Dockerfile ARG values as repeated flags. flag is the
+// spelling the backend wants: "--build-arg" with the docker CLI, "--opt" with
+// buildctl (whose values carry a "build-arg:" prefix, supplied by the caller).
+func buildArgFlags(cfg *proto.BuildConfig, flag, prefix string) []string {
+ if cfg == nil || len(cfg.BuildArgs) == 0 {
+ return nil
+ }
+ out := make([]string, 0, len(cfg.BuildArgs)*2)
+ for _, k := range sortedKeys(cfg.BuildArgs) {
+ out = append(out, flag, prefix+k+"="+cfg.BuildArgs[k])
+ }
+ return out
+}
+
+// contextLabel renders a context directory for the build log, relative to the
+// source root so the line reads like the pipeline file ("." or "services/api")
+// rather than leaking the runner's own workdir layout.
+func contextLabel(workdir, dir string) string {
+ rel, err := filepath.Rel(workdir, dir)
+ if err != nil || rel == "" {
+ return "."
+ }
+ return rel
+}
+
// hasFile reports whether dir contains a regular file named name.
func hasFile(dir, name string) bool {
info, err := os.Stat(filepath.Join(dir, name))
@@ -95,12 +162,7 @@ func packArgs(tag, builder string, cfg *proto.BuildConfig) []string {
args = append(args, "--buildpack", bp)
}
}
- keys := make([]string, 0, len(cfg.BuildEnv))
- for k := range cfg.BuildEnv {
- keys = append(keys, k)
- }
- sort.Strings(keys)
- for _, k := range keys {
+ for _, k := range sortedKeys(cfg.BuildEnv) {
args = append(args, "--env", k+"="+cfg.BuildEnv[k])
}
return args
diff --git a/executor_docker.go b/executor_docker.go
index bc25176..6be66e4 100644
--- a/executor_docker.go
+++ b/executor_docker.go
@@ -216,10 +216,19 @@ func (r *dockerJobRun) build(ctx context.Context, step proto.StepSpec, log func(
default: // dockerfile
buildArgs := []string{"build", "-t", tag}
if df := dockerfilePath(step.Build); df != "Dockerfile" {
+ // -f is resolved from the working directory (the source root), not from
+ // the context — same as the docker CLI, so `dockerfile: docker/Dockerfile`
+ // with the default context behaves the way an operator expects.
buildArgs = append(buildArgs, "-f", df)
}
- buildArgs = append(buildArgs, ".")
- log("building " + tag)
+ cdir, err := contextDir(r.workdir, step.Build)
+ if err != nil {
+ return StepResult{}, err
+ }
+ buildArgs = append(buildArgs, buildArgFlags(step.Build, "--build-arg", "")...)
+ rel := contextLabel(r.workdir, cdir)
+ buildArgs = append(buildArgs, rel)
+ log("building " + tag + " (context " + rel + ")")
name, args := r.authCmd(r.e.docker, buildArgs...)
if code, err := r.e.cmd.run(ctx, r.workdir, log, name, args...); err != nil {
return StepResult{}, fmt.Errorf("docker build: %w", err)
diff --git a/executor_docker_test.go b/executor_docker_test.go
index abdd064..a66e901 100644
--- a/executor_docker_test.go
+++ b/executor_docker_test.go
@@ -344,3 +344,122 @@ func TestDeployStepIsNoop(t *testing.T) {
t.Errorf("deploy must not run any command, got %v", fc.calls)
}
}
+
+// The reported bug: `dockerfile:` reached the runner but a custom path has to
+// become `-f`, and the context has to stay independent of it — a monorepo keeps
+// docker/Dockerfile while still building from the root.
+func TestBuildStepDockerfileAndContext(t *testing.T) {
+ cases := []struct {
+ name string
+ build *proto.BuildConfig
+ want string
+ }{
+ {
+ "defaults are unchanged",
+ nil,
+ "docker build -t reg.example.com/ws-42/web:9 .",
+ },
+ {
+ "custom dockerfile, default context",
+ &proto.BuildConfig{Method: "dockerfile", Dockerfile: "docker/Dockerfile"},
+ "docker build -t reg.example.com/ws-42/web:9 -f docker/Dockerfile .",
+ },
+ {
+ "custom context, default dockerfile",
+ &proto.BuildConfig{Method: "dockerfile", Context: "services/api"},
+ "docker build -t reg.example.com/ws-42/web:9 services/api",
+ },
+ {
+ "both, independently",
+ &proto.BuildConfig{Method: "dockerfile", Dockerfile: "docker/Dockerfile", Context: "services/api"},
+ "docker build -t reg.example.com/ws-42/web:9 -f docker/Dockerfile services/api",
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ fc := &fakeCommander{digestOut: "reg.example.com/ws-42/web@sha256:cafebabe"}
+ e := newTestExecutor(t, fc)
+ job := proto.JobSpec{RunID: 9, Repository: "reg.example.com/ws-42/web", Commit: "abcdef1234567890"}
+ run, err := e.Begin(context.Background(), job, func(string) {})
+ if err != nil {
+ t.Fatalf("Begin: %v", err)
+ }
+ defer run.Close()
+ // The context directory must exist in the checked-out source.
+ if tc.build != nil && tc.build.Context != "" {
+ if err := os.MkdirAll(filepath.Join(run.(*dockerJobRun).workdir, tc.build.Context), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := run.Step(context.Background(),
+ proto.StepSpec{Ordinal: 0, Name: "build", Uses: "build", Build: tc.build},
+ func(string) {}); err != nil {
+ t.Fatalf("build step: %v", err)
+ }
+ if !fc.called(tc.want) {
+ t.Errorf("build command wrong:\n got %v\n want %q", fc.calls, tc.want)
+ }
+ })
+ }
+}
+
+// A context is joined against the checked-out source, so an absolute path or a
+// climbing one would hand the build the runner's own filesystem. The runner is
+// shared across a workspace's pipelines and a pipeline file is editable by anyone
+// who can push a branch, so it re-checks rather than trusting the control plane.
+func TestBuildStepRejectsEscapingContext(t *testing.T) {
+ for _, bad := range []string{"/etc", "../../etc", "sub/../../.."} {
+ t.Run(bad, func(t *testing.T) {
+ fc := &fakeCommander{digestOut: "reg.example.com/ws-42/web@sha256:cafebabe"}
+ e := newTestExecutor(t, fc)
+ job := proto.JobSpec{RunID: 10, Repository: "reg.example.com/ws-42/web", Commit: "abcdef1234567890"}
+ run, err := e.Begin(context.Background(), job, func(string) {})
+ if err != nil {
+ t.Fatalf("Begin: %v", err)
+ }
+ defer run.Close()
+ _, err = run.Step(context.Background(), proto.StepSpec{
+ Ordinal: 0, Name: "build", Uses: "build",
+ Build: &proto.BuildConfig{Method: "dockerfile", Context: bad},
+ }, func(string) {})
+ if err == nil {
+ t.Fatalf("accepted context %q — the build would read outside the repository", bad)
+ }
+ for _, c := range fc.calls {
+ if strings.HasPrefix(c, "docker build") {
+ t.Errorf("ran a build despite the bad context: %q", c)
+ }
+ }
+ })
+ }
+}
+
+// Build args must be deterministic (sorted) so the argv is testable, and must sit
+// before the positional context — docker reads the context as the last argument.
+func TestBuildStepBuildArgs(t *testing.T) {
+ fc := &fakeCommander{digestOut: "reg.example.com/ws-42/web@sha256:cafebabe"}
+ e := newTestExecutor(t, fc)
+ job := proto.JobSpec{RunID: 11, Repository: "reg.example.com/ws-42/web", Commit: "abcdef1234567890"}
+ run, err := e.Begin(context.Background(), job, func(string) {})
+ if err != nil {
+ t.Fatalf("Begin: %v", err)
+ }
+ defer run.Close()
+
+ if _, err := run.Step(context.Background(), proto.StepSpec{
+ Ordinal: 0, Name: "build", Uses: "build",
+ Build: &proto.BuildConfig{
+ Method: "dockerfile",
+ Dockerfile: "docker/Dockerfile",
+ BuildArgs: map[string]string{"VERSION": "1.2.3", "APP_ENV": "prod"},
+ },
+ }, func(string) {}); err != nil {
+ t.Fatalf("build step: %v", err)
+ }
+
+ want := "docker build -t reg.example.com/ws-42/web:11 -f docker/Dockerfile " +
+ "--build-arg APP_ENV=prod --build-arg VERSION=1.2.3 ."
+ if !fc.called(want) {
+ t.Errorf("build command wrong:\n got %v\n want %q", fc.calls, want)
+ }
+}
diff --git a/pipelines.png b/pipelines.png
new file mode 100644
index 0000000..1962a6a
Binary files /dev/null and b/pipelines.png differ
diff --git a/proto/proto.go b/proto/proto.go
index ae63c35..3ea52c8 100644
--- a/proto/proto.go
+++ b/proto/proto.go
@@ -64,14 +64,28 @@ type StepSpec struct {
type BuildConfig struct {
// Method is "" | "auto" | "dockerfile" | "buildpack" (empty/auto auto-detects).
Method string `json:"method,omitempty"`
- // Dockerfile is the Dockerfile path for the dockerfile method (default "Dockerfile").
+ // Dockerfile is the Dockerfile path for the dockerfile method (default "Dockerfile"),
+ // relative to the checked-out source root — NOT to Context, matching
+ // `docker build -f `, where the two are independent.
Dockerfile string `json:"dockerfile,omitempty"`
+ // Context is the build context directory, relative to the checked-out source
+ // root (default: the root itself). A monorepo commonly keeps its Dockerfile in
+ // docker/ while still building from the root, so this must be settable apart
+ // from Dockerfile.
+ Context string `json:"context,omitempty"`
// Builder is the CNB builder image for the buildpack method (empty = runner default).
Builder string `json:"builder,omitempty"`
// Buildpacks are extra buildpacks to apply (pack --buildpack).
Buildpacks []string `json:"buildpacks,omitempty"`
// BuildEnv is build-time env for the buildpack method (pack --env KEY=VALUE).
BuildEnv map[string]string `json:"build_env,omitempty"`
+ // BuildArgs are Dockerfile ARG values for the dockerfile method
+ // (docker build --build-arg KEY=VALUE). The buildpack equivalent is BuildEnv.
+ //
+ // NOT for secrets: a build arg is recorded in the image's own history, so
+ // anyone who can pull the image can read it back. Pass credentials through the
+ // job's env instead.
+ BuildArgs map[string]string `json:"build_args,omitempty"`
}
// FrameType is the kind of report a runner sends back.
diff --git a/runner.go b/runner.go
index d6d9a8b..eb6a1e7 100644
--- a/runner.go
+++ b/runner.go
@@ -34,7 +34,7 @@ const connectPath = "/api/v1/runner/connect"
// Config configures the runner runtime.
type Config struct {
- ControlURL string // e.g. https://panel.example.com
+ ControlURL string // e.g. https://miabi.example.com
Token string // registration token (mbr_...)
Insecure bool // skip TLS verification of the control plane
Version string // runner build version, reported to the control plane