diff --git a/cmd/github-actions-runner-job/main.go b/cmd/github-actions-runner-job/main.go index 70e128f..a9edf7f 100644 --- a/cmd/github-actions-runner-job/main.go +++ b/cmd/github-actions-runner-job/main.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "bytes" "context" "encoding/json" @@ -13,15 +14,17 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "time" "github.com/GoCodeAlone/workflow-plugin-github/internal" ) const ( - computeProtocolVersion = "compute.v1alpha1" - providerOperation = "ephemeral_runner_job" - proofArtifactName = "github-runner-proof.json" + computeProtocolVersion = "compute.v1alpha1" + providerOperation = "ephemeral_runner_job" + proofArtifactName = "github-runner-proof.json" + defaultRunnerJobTimeout = 30 * time.Minute ) func main() { @@ -133,6 +136,14 @@ func decodeEphemeralRunnerRequest(input json.RawMessage) (internal.EphemeralRunn if err := decoder.Decode(&req); err != nil { return internal.EphemeralRunnerJobRequest{}, fmt.Errorf("decode ephemeral runner request: %w", err) } + if req.TimeoutSeconds < 0 { + return internal.EphemeralRunnerJobRequest{}, fmt.Errorf("timeout_seconds must not be negative") + } + if req.TimeoutSeconds > 0 { + req.Timeout = time.Duration(req.TimeoutSeconds) * time.Second + } else { + req.Timeout = defaultRunnerJobTimeout + } return req, nil } @@ -274,7 +285,7 @@ func (d *runnerDriver) RunGitHubJob(ctx context.Context, mode internal.Ephemeral _ = runner.wait() return result, err } - if err := runner.wait(); err != nil { + if err := runner.waitForGitHubJob(ctx); err != nil { return result, err } return result, nil @@ -363,8 +374,17 @@ type runningCommand struct { path string cancel context.CancelFunc cmd *exec.Cmd + mu sync.Mutex stdout bytes.Buffer stderr bytes.Buffer + result chan runnerCompletion + done chan error + once sync.Once +} + +type runnerCompletion struct { + success bool + line string } func startCommand(ctx context.Context, path, dir string, args ...string) (*runningCommand, error) { @@ -372,26 +392,106 @@ func startCommand(ctx context.Context, path, dir string, args ...string) (*runni cmd := exec.CommandContext(runCtx, path, args...) cmd.Dir = dir cmd.Env = os.Environ() - running := &runningCommand{path: path, cancel: cancel, cmd: cmd} - cmd.Stdout = &running.stdout - cmd.Stderr = &running.stderr + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("%s stdout pipe failed: %w", filepath.Base(path), err) + } + stderrPipe, err := cmd.StderrPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("%s stderr pipe failed: %w", filepath.Base(path), err) + } + running := &runningCommand{ + path: path, + cancel: cancel, + cmd: cmd, + result: make(chan runnerCompletion, 1), + done: make(chan error, 1), + } if err := cmd.Start(); err != nil { cancel() return nil, fmt.Errorf("%s start failed: %w", filepath.Base(path), err) } + go running.captureOutput(stdoutPipe, &running.stdout) + go running.captureOutput(stderrPipe, &running.stderr) + go func() { + running.done <- cmd.Wait() + }() return running, nil } func (r *runningCommand) wait() error { - err := r.cmd.Wait() + err := <-r.done r.cancel() if err != nil { - output := strings.TrimSpace(strings.Join([]string{r.stdout.String(), r.stderr.String()}, "\n")) + output := r.output() return fmt.Errorf("%s failed: %w: %s", filepath.Base(r.path), err, output) } return nil } +func (r *runningCommand) waitForGitHubJob(ctx context.Context) error { + select { + case err := <-r.done: + r.cancel() + if err != nil { + return fmt.Errorf("%s failed: %w: %s", filepath.Base(r.path), err, r.output()) + } + return nil + case result := <-r.result: + r.cancel() + err := <-r.done + if result.success { + return nil + } + if err != nil { + return fmt.Errorf("%s reported failed job and exited with %w: %s", filepath.Base(r.path), err, result.line) + } + return fmt.Errorf("%s reported failed job: %s", filepath.Base(r.path), result.line) + case <-ctx.Done(): + r.cancel() + err := <-r.done + if err != nil { + return fmt.Errorf("%s timed out waiting for GitHub job completion: %w: %s", filepath.Base(r.path), ctx.Err(), r.output()) + } + return ctx.Err() + } +} + +func (r *runningCommand) captureOutput(reader io.Reader, buffer *bytes.Buffer) { + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + line := scanner.Text() + r.mu.Lock() + _, _ = buffer.WriteString(line) + _ = buffer.WriteByte('\n') + r.mu.Unlock() + if result, ok := parseRunnerCompletion(line); ok { + r.once.Do(func() { + r.result <- result + }) + } + } +} + +func (r *runningCommand) output() string { + r.mu.Lock() + defer r.mu.Unlock() + return strings.TrimSpace(strings.Join([]string{r.stdout.String(), r.stderr.String()}, "\n")) +} + +func parseRunnerCompletion(line string) (runnerCompletion, bool) { + normalized := strings.ToLower(strings.TrimSpace(line)) + if !strings.Contains(normalized, "completed with result:") { + return runnerCompletion{}, false + } + return runnerCompletion{ + success: strings.Contains(normalized, "completed with result: succeeded") || strings.Contains(normalized, "completed with result: success"), + line: strings.TrimSpace(line), + }, true +} + func writeProofArtifact(result internal.EphemeralRunnerJobResult) error { data, err := json.MarshalIndent(result, "", " ") if err != nil { diff --git a/cmd/github-actions-runner-job/main_test.go b/cmd/github-actions-runner-job/main_test.go index 17d3c7f..909960a 100644 --- a/cmd/github-actions-runner-job/main_test.go +++ b/cmd/github-actions-runner-job/main_test.go @@ -150,6 +150,115 @@ func TestT915CommandRunsDynamicProviderEnvelopeThroughSidecarAndRunner(t *testin } } +func TestT915CommandTreatsRunnerSuccessMarkerAsCompletion(t *testing.T) { + var tokenCalls, dispatchCalls, deleteCalls int + workspace := t.TempDir() + sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer provider-token" { + t.Fatalf("authorization = %q", got) + } + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/actions/orgs/GoCodeAlone/runners/registration-token": + tokenCalls++ + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"token":"runner-registration-token","expires_at":"2026-06-26T22:00:00Z"}`)) + case r.Method == http.MethodPost && r.URL.Path == "/v1/actions/repos/GoCodeAlone/workflow-compute/workflows/dogfood.yml/dispatches": + dispatchCalls++ + for range 20 { + if _, err := os.Stat(filepath.Join(workspace, "run.started")); err == nil { + break + } + time.Sleep(50 * time.Millisecond) + } + if _, err := os.Stat(filepath.Join(workspace, "run.started")); err != nil { + http.Error(w, "runner listener was not started before dispatch", http.StatusConflict) + return + } + if err := os.WriteFile(filepath.Join(workspace, "dispatch.seen"), []byte("1\n"), 0o600); err != nil { + t.Fatalf("write dispatch marker: %v", err) + } + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodDelete && r.URL.Path == "/v1/actions/orgs/GoCodeAlone/runners/42": + deleteCalls++ + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected sidecar request: %s %s", r.Method, r.URL.Path) + } + })) + t.Cleanup(sidecar.Close) + + runnerDir := t.TempDir() + writeExecutable(t, filepath.Join(runnerDir, "config.sh"), "#!/bin/sh\nprintf '{\"agentId\":42}\\n' > .runner\n") + writeExecutable(t, filepath.Join(runnerDir, "run.sh"), "#!/bin/sh\ntouch \"$GITHUB_ACTIONS_RUNNER_JOB_TEST_DIR/run.started\"\nwhile [ ! -f \"$GITHUB_ACTIONS_RUNNER_JOB_TEST_DIR/dispatch.seen\" ]; do sleep 0.1; done\nprintf '2026-06-30T12:58:48Z: Job provider-target completed with result: Succeeded\\n'\ntouch \"$GITHUB_ACTIONS_RUNNER_JOB_TEST_DIR/success.marker\"\nwhile true; do sleep 1; done\n") + t.Chdir(workspace) + t.Setenv("COMPUTE_GITHUB_RUNNER_PROVIDER_URL", sidecar.URL) + t.Setenv("COMPUTE_GITHUB_RUNNER_PROVIDER_TOKEN", "provider-token") + t.Setenv("GITHUB_ACTIONS_RUNNER_DIR", runnerDir) + t.Setenv("GITHUB_ACTIONS_RUNNER_JOB_TEST_DIR", workspace) + + input := strings.NewReader(`{ + "protocol_version":"compute.v1alpha1", + "task_id":"task-abcdef9876543210", + "lease_id":"lease-1", + "provider_config":{"plugin_id":"workflow-plugin-github","provider_id":"github-actions-runner"}, + "operation":"ephemeral_runner_job", + "input":{ + "mode":"dispatch_then_wait", + "environment":"stg", + "os":"linux", + "worker_id":"worker-0123456789abcdef", + "task_id":"task-abcdef9876543210", + "organization":"GoCodeAlone", + "repository":"GoCodeAlone/workflow-compute", + "workflow":"dogfood.yml", + "ref":"main", + "runner_group":"ephemeral", + "timeout_seconds":2 + } + }`) + var stdout, stderr bytes.Buffer + if err := runWithIO([]string{}, input, &stdout, &stderr); err != nil { + t.Fatalf("run dynamic provider: %v\nstderr:\n%s", err, stderr.String()) + } + if tokenCalls != 1 || dispatchCalls != 1 || deleteCalls != 1 { + t.Fatalf("sidecar calls: token=%d dispatch=%d delete=%d", tokenCalls, dispatchCalls, deleteCalls) + } + var result struct { + Artifacts []string `json:"artifacts"` + } + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("decode stdout: %v\n%s", err, stdout.String()) + } + if len(result.Artifacts) != 1 || result.Artifacts[0] != "github-runner-proof.json" { + t.Fatalf("artifacts = %#v", result.Artifacts) + } + proof := readFile(t, filepath.Join(workspace, "github-runner-proof.json")) + for _, want := range []string{"wfc-stg-ghp-linux-abcdef987249-543210f71ee4", "task-abcdef9876543210", "removed"} { + if !strings.Contains(proof, want) { + t.Fatalf("proof missing %q:\n%s", want, proof) + } + } +} + +func TestT915RunnerCompletionMarkerRejectsFailedJob(t *testing.T) { + for _, line := range []string{ + "2026-06-30T12:58:48Z: Job provider-target completed with result: Failed", + "Job provider-target completed with result: Cancelled", + } { + result, ok := parseRunnerCompletion(line) + if !ok { + t.Fatalf("completion marker was not detected for %q", line) + } + if result.success { + t.Fatalf("failed marker treated as success: %+v", result) + } + } + result, ok := parseRunnerCompletion("Job provider-target completed with result: Succeeded") + if !ok || !result.success { + t.Fatalf("success marker not accepted: result=%+v ok=%v", result, ok) + } +} + func TestT915CommandRejectsUnknownDynamicInputFields(t *testing.T) { var stdout, stderr bytes.Buffer err := runWithIO([]string{}, strings.NewReader(`{ diff --git a/internal/ephemeral_runner_job.go b/internal/ephemeral_runner_job.go index 8ec1cdb..45dbe2b 100644 --- a/internal/ephemeral_runner_job.go +++ b/internal/ephemeral_runner_job.go @@ -33,6 +33,7 @@ type EphemeralRunnerJobRequest struct { Ref string `json:"ref,omitempty"` WorkflowInputs map[string]string `json:"workflow_inputs,omitempty"` RunnerGroup string `json:"runner_group,omitempty"` + TimeoutSeconds int `json:"timeout_seconds,omitempty"` Timeout time.Duration `json:"-"` RequiredRuntimeCaps []string `json:"required_runtime_caps,omitempty"` AdvertisedCaps []string `json:"advertised_caps,omitempty"`