From 2a83fc5e7b07d7f130bf0a1e7129ac13079bf9c3 Mon Sep 17 00:00:00 2001 From: zoetaka38 Date: Tue, 7 Jul 2026 08:06:40 +0200 Subject: [PATCH] feat(ci): run docker:// container actions `uses: docker://` steps now execute instead of being skipped. The image runs with the job workspace bind-mounted, network isolated, and the step's `with:` inputs passed as GitHub-style INPUT_* env vars (uppercased, non-alnum to underscore, ${{ }} interpolated). Output is captured as job log lines (secret-masked) and a non-zero exit fails the job. The container runner is injectable for tests. Non-checkout, non-docker actions (JS/marketplace actions, which require the full Actions runtime) remain logged as unsupported and skipped without failing the run. Verified live: `uses: docker://og-test-action:latest` with `with: {greeting: hello-from-workflow, my-name: ${{ github.actor }}}` runs the container and logs `greeting=hello-from-workflow name=alice` (dash normalized, actor interpolated); a following run step still executes. --- backend/internal/worker/ci_worker.go | 101 +++++++++++++++++++--- backend/internal/worker/ci_worker_test.go | 79 +++++++++++++++++ 2 files changed, 166 insertions(+), 14 deletions(-) diff --git a/backend/internal/worker/ci_worker.go b/backend/internal/worker/ci_worker.go index ace889f..4509036 100644 --- a/backend/internal/worker/ci_worker.go +++ b/backend/internal/worker/ci_worker.go @@ -121,17 +121,23 @@ type StreamingCommandRunner func(ctx context.Context, workdir string, env []stri // Implements the built-in actions/checkout; injectable for tests. type CheckoutFunc func(ctx context.Context, gitPath, ref, dest string) error +// ContainerActionFunc runs a `uses: docker://` container action with the +// workspace mounted and the given env (INPUT_* action inputs plus step env), +// returning its combined output. Injectable for tests. +type ContainerActionFunc func(ctx context.Context, image, workdir string, env []string) ([]byte, error) + type CIWorker struct { - db *sql.DB - decrypt SecretDecrypter - runStep CommandRunner - runStepStream StreamingCommandRunner - checkout CheckoutFunc - stepWait time.Duration - logRepo domainrepo.IJobLogRepository - jobRepo domainrepo.IWorkflowJobRepository - logPublisher *queue.JobLogPublisher - sandbox sandbox + db *sql.DB + decrypt SecretDecrypter + runStep CommandRunner + runStepStream StreamingCommandRunner + checkout CheckoutFunc + containerAction ContainerActionFunc + stepWait time.Duration + logRepo domainrepo.IJobLogRepository + jobRepo domainrepo.IWorkflowJobRepository + logPublisher *queue.JobLogPublisher + sandbox sandbox } func NewCIWorker(db *sql.DB) *CIWorker { @@ -144,6 +150,7 @@ func NewCIWorker(db *sql.DB) *CIWorker { w.runStep = w.defaultCommandRunner w.runStepStream = w.defaultStreamingCommandRunner w.checkout = defaultCheckout + w.containerAction = defaultContainerAction return w } @@ -153,6 +160,52 @@ func (w *CIWorker) WithCheckout(fn CheckoutFunc) *CIWorker { return w } +// WithContainerAction overrides the docker:// action runner (used in tests). +func (w *CIWorker) WithContainerAction(fn ContainerActionFunc) *CIWorker { + w.containerAction = fn + return w +} + +// defaultContainerAction runs a container action image with the workspace bind +// mounted, isolated from the network, passing INPUT_*/step env via -e. +func defaultContainerAction(ctx context.Context, image, workdir string, env []string) ([]byte, error) { + args := []string{"run", "--rm", "--network", "none", "-v", workdir + ":/github/workspace", "-w", "/github/workspace"} + for _, e := range env { + args = append(args, "-e", e) + } + args = append(args, image) + return exec.CommandContext(ctx, "docker", args...).CombinedOutput() +} + +// actionInputEnv converts a step's `with:` inputs into GitHub-style INPUT_* +// environment variables (uppercased, non-alphanumerics to underscores), +// interpolating any ${{ }} expressions with the step context. +func actionInputEnv(with map[string]string, evalCtx *workflow.EvalContext) []string { + env := make([]string, 0, len(with)) + for k, v := range with { + if strings.Contains(v, "${{") { + if iv, err := workflow.InterpolateString(v, evalCtx); err == nil { + v = iv + } + } + env = append(env, "INPUT_"+inputKeyToEnv(k)+"="+v) + } + sort.Strings(env) + return env +} + +func inputKeyToEnv(k string) string { + var b strings.Builder + for _, r := range strings.ToUpper(k) { + if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + return b.String() +} + // defaultCheckout clones the bare repository into dest and checks out ref using // the git binary. func defaultCheckout(ctx context.Context, gitPath, ref, dest string) error { @@ -638,8 +691,10 @@ func (w *CIWorker) runJobSteps(ctx context.Context, s runJobSpec) bool { continue } + stepEnv := buildStepEnv([]map[string]string{mergedEnv}, s.secretEnv) + if step.Uses != "" { - if w.runUsesStep(ctx, s, step, i, instWorkdir) { + if w.runUsesStep(ctx, s, step, i, instWorkdir, evalCtx, stepEnv) { jobFailed = true } continue @@ -649,7 +704,6 @@ func (w *CIWorker) runJobSteps(ctx context.Context, s runJobSpec) bool { } script, _ := workflow.InterpolateString(step.Run, evalCtx) - stepEnv := buildStepEnv([]map[string]string{mergedEnv}, s.secretEnv) stepCtx, cancel := context.WithTimeout(ctx, w.stepWait) if s.useStreaming { @@ -704,7 +758,7 @@ func (w *CIWorker) runJobSteps(ctx context.Context, s runJobSpec) bool { // the workspace from the run's commit; any other action is logged as // unsupported and skipped (not failed), so workflows that reference marketplace // actions still make progress. Returns true only if a supported action failed. -func (w *CIWorker) runUsesStep(ctx context.Context, s runJobSpec, step workflow.IRStep, i int, workdir string) bool { +func (w *CIWorker) runUsesStep(ctx context.Context, s runJobSpec, step workflow.IRStep, i int, workdir string, evalCtx *workflow.EvalContext, stepEnv []string) bool { if isCheckoutAction(step.Uses) { ref := s.payload.HeadSHA if ref == "" { @@ -717,7 +771,26 @@ func (w *CIWorker) runUsesStep(ctx context.Context, s runJobSpec, step workflow. } return false } - w.emitUsesLine(ctx, s, i, fmt.Sprintf("Skipping unsupported action %q (only actions/checkout is built in)", step.Uses)) + + // Container actions (uses: docker://) are self-contained: run the + // image with the workspace mounted and `with:` inputs passed as INPUT_*. + if step.UsesRef != nil && step.UsesRef.Kind == "docker" && step.UsesRef.Image != "" { + w.emitUsesLine(ctx, s, i, "Run "+step.Uses) + env := append(append([]string{}, stepEnv...), actionInputEnv(step.With, evalCtx)...) + out, err := w.containerAction(ctx, step.UsesRef.Image, workdir, env) + for _, line := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") { + if line != "" { + w.emitUsesLine(ctx, s, i, maskSecrets(line, s.secretValues)) + } + } + if err != nil { + w.emitUsesLine(ctx, s, i, "action failed: "+maskSecrets(err.Error(), s.secretValues)) + return true + } + return false + } + + w.emitUsesLine(ctx, s, i, fmt.Sprintf("Skipping unsupported action %q (built-in: actions/checkout and docker:// container actions)", step.Uses)) return false } diff --git a/backend/internal/worker/ci_worker_test.go b/backend/internal/worker/ci_worker_test.go index 9c67196..f3bdb40 100644 --- a/backend/internal/worker/ci_worker_test.go +++ b/backend/internal/worker/ci_worker_test.go @@ -697,6 +697,85 @@ jobs: } } +func TestCIDockerContainerActionRuns(t *testing.T) { + db := newCITestDB(t) + ctx := context.Background() + orgID, repoID, runID := "org-dk", "repo-dk", "run-dk" + mustExec(t, db, `INSERT INTO organizations (id, login, name, plan_tier) VALUES (?, ?, ?, ?)`, orgID, "acme", "Acme", planTierPro) + mustExec(t, db, `INSERT INTO repositories (id, organization_id, name) VALUES (?, ?, ?)`, repoID, orgID, "widgets") + mustExec(t, db, `INSERT INTO workflow_runs (id, organization_id, repository_id, workflow, status) VALUES (?, ?, ?, ?, ?)`, runID, orgID, repoID, "ci.yml", ciStatusQueued) + + var gotImage string + var gotEnv []string + worker := NewCIWorker(db). + WithContainerAction(func(_ context.Context, image, _ string, env []string) ([]byte, error) { + gotImage = image + gotEnv = env + return []byte("container ran\n"), nil + }) + + payload, _ := json.Marshal(CIRunPayload{ + WorkflowRunID: runID, RepositoryID: repoID, OrganizationID: orgID, + WorkflowYAML: []byte(`name: CI +on: push +jobs: + build: + steps: + - uses: docker://alpine:3 + with: + greeting: hello + my-name: world +`), + }) + if err := worker.HandleCIRun(ctx, asynq.NewTask(TypeCIRun, payload)); err != nil { + t.Fatalf("HandleCIRun: %v", err) + } + if gotImage != "alpine:3" { + t.Errorf("container image = %q, want alpine:3", gotImage) + } + joined := strings.Join(gotEnv, "\n") + if !strings.Contains(joined, "INPUT_GREETING=hello") { + t.Errorf("expected INPUT_GREETING=hello in env; got %v", gotEnv) + } + if !strings.Contains(joined, "INPUT_MY_NAME=world") { + t.Errorf("expected INPUT_MY_NAME=world (dash normalized) in env; got %v", gotEnv) + } +} + +func TestCIDockerContainerActionFailureFailsJob(t *testing.T) { + db := newCITestDB(t) + ctx := context.Background() + orgID, repoID, runID := "org-dkf", "repo-dkf", "run-dkf" + mustExec(t, db, `INSERT INTO organizations (id, login, name, plan_tier) VALUES (?, ?, ?, ?)`, orgID, "acme", "Acme", planTierPro) + mustExec(t, db, `INSERT INTO repositories (id, organization_id, name) VALUES (?, ?, ?)`, repoID, orgID, "widgets") + mustExec(t, db, `INSERT INTO workflow_runs (id, organization_id, repository_id, workflow, status) VALUES (?, ?, ?, ?, ?)`, runID, orgID, repoID, "ci.yml", ciStatusQueued) + + worker := NewCIWorker(db). + WithContainerAction(func(_ context.Context, _, _ string, _ []string) ([]byte, error) { + return []byte("boom\n"), errors.New("exit status 1") + }) + payload, _ := json.Marshal(CIRunPayload{ + WorkflowRunID: runID, RepositoryID: repoID, OrganizationID: orgID, + WorkflowYAML: []byte(`name: CI +on: push +jobs: + build: + steps: + - uses: docker://alpine:3 +`), + }) + if err := worker.HandleCIRun(ctx, asynq.NewTask(TypeCIRun, payload)); err != nil { + t.Fatalf("HandleCIRun: %v", err) + } + var conclusion sql.NullString + if err := db.QueryRowContext(ctx, `SELECT conclusion FROM workflow_runs WHERE id = ?`, runID).Scan(&conclusion); err != nil { + t.Fatalf("query: %v", err) + } + if got := strings.SplitN(conclusion.String, "\n", 2)[0]; got != ciConclusionFailure { + t.Errorf("conclusion = %q, want failure (container action errored)", got) + } +} + func mustExec(t *testing.T, db *sql.DB, q string, args ...any) { t.Helper() if _, err := db.Exec(q, args...); err != nil {