diff --git a/cmd/github-actions-runner-job/main.go b/cmd/github-actions-runner-job/main.go index a9edf7f..9daa8f6 100644 --- a/cmd/github-actions-runner-job/main.go +++ b/cmd/github-actions-runner-job/main.go @@ -27,6 +27,8 @@ const ( defaultRunnerJobTimeout = 30 * time.Minute ) +var githubJobPollInterval = 5 * time.Second + func main() { if err := run(); err != nil { fmt.Fprintf(os.Stderr, "github-actions-runner-job failed: %v\n", err) @@ -190,6 +192,43 @@ func (c *providerSidecarClient) dispatchWorkflow(ctx context.Context, repository return c.do(ctx, http.MethodPost, path, body, http.StatusNoContent, nil) } +func (c *providerSidecarClient) workflowRuns(ctx context.Context, repository, workflow string, createdAfter time.Time) ([]internal.GitHubWorkflowRun, error) { + owner, repo, err := splitRepository(repository) + if err != nil { + return nil, err + } + path := "/v1/actions/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/workflows/" + url.PathEscape(workflow) + "/runs" + query := url.Values{} + if !createdAfter.IsZero() { + query.Set("created_after", createdAfter.UTC().Format(time.RFC3339)) + } + if encoded := query.Encode(); encoded != "" { + path += "?" + encoded + } + var out struct { + WorkflowRuns []internal.GitHubWorkflowRun `json:"workflow_runs"` + } + if err := c.do(ctx, http.MethodGet, path, nil, http.StatusOK, &out); err != nil { + return nil, err + } + return out.WorkflowRuns, nil +} + +func (c *providerSidecarClient) workflowRunJobs(ctx context.Context, repository string, runID int64) ([]internal.GitHubWorkflowJob, error) { + owner, repo, err := splitRepository(repository) + if err != nil { + return nil, err + } + path := fmt.Sprintf("/v1/actions/repos/%s/%s/actions/runs/%d/jobs", url.PathEscape(owner), url.PathEscape(repo), runID) + var out struct { + Jobs []internal.GitHubWorkflowJob `json:"jobs"` + } + if err := c.do(ctx, http.MethodGet, path, nil, http.StatusOK, &out); err != nil { + return nil, err + } + return out.Jobs, nil +} + func (c *providerSidecarClient) removeOrgRunner(ctx context.Context, organization string, runnerID int64) error { path := fmt.Sprintf("/v1/actions/orgs/%s/runners/%d", url.PathEscape(organization), runnerID) return c.do(ctx, http.MethodDelete, path, nil, http.StatusNoContent, nil) @@ -280,12 +319,20 @@ func (d *runnerDriver) RunGitHubJob(ctx context.Context, mode internal.Ephemeral if err != nil { return result, err } + dispatchedAfter := time.Now().UTC().Add(-10 * time.Second) if err := d.sidecar.dispatchWorkflow(ctx, d.req.Repository, d.req.Workflow, dispatchRef, dispatchInputs); err != nil { runner.cancel() _ = runner.wait() return result, err } - if err := runner.waitForGitHubJob(ctx); err != nil { + completion, err := d.waitForGitHubCompletion(ctx, runner, spec.RunnerName, dispatchedAfter) + if completion.WorkflowRunID != 0 { + result.WorkflowRunID = completion.WorkflowRunID + } + if completion.WorkflowJobID != 0 { + result.WorkflowJobID = completion.WorkflowJobID + } + if err != nil { return result, err } return result, nil @@ -317,6 +364,110 @@ func (d *runnerDriver) workflowDispatchInputs(spec internal.EphemeralRunnerJobSp return inputs, nil } +type githubJobCompletion struct { + WorkflowRunID int64 + WorkflowJobID int64 + Success bool + Terminal bool + Message string +} + +func (d *runnerDriver) waitForGitHubCompletion(ctx context.Context, runner *runningCommand, runnerName string, dispatchedAfter time.Time) (githubJobCompletion, error) { + ticker := time.NewTicker(githubJobPollInterval) + defer ticker.Stop() + var last githubJobCompletion + for { + select { + case err := <-runner.done: + runner.cancel() + if err != nil { + return last, fmt.Errorf("%s failed: %w: %s", filepath.Base(runner.path), err, runner.output()) + } + if completion, err := d.observeGitHubJob(ctx, runnerName, dispatchedAfter); err == nil && completion.WorkflowRunID != 0 { + if completion.Terminal && !completion.Success { + return completion, fmt.Errorf("github workflow job failed: %s", completion.Message) + } + return completion, nil + } + return last, nil + case result := <-runner.result: + runner.cancel() + err := <-runner.done + if result.success { + if completion, observeErr := d.observeGitHubJob(ctx, runnerName, dispatchedAfter); observeErr == nil && completion.WorkflowRunID != 0 { + if completion.Terminal && !completion.Success { + return completion, fmt.Errorf("github workflow job failed after success marker: %s", completion.Message) + } + return completion, nil + } + return last, nil + } + if err != nil { + return last, fmt.Errorf("%s reported failed job and exited with %w: %s", filepath.Base(runner.path), err, result.line) + } + return last, fmt.Errorf("%s reported failed job: %s", filepath.Base(runner.path), result.line) + case <-ticker.C: + completion, err := d.observeGitHubJob(ctx, runnerName, dispatchedAfter) + if err != nil { + return last, err + } + if completion.WorkflowRunID != 0 { + last = completion + } + if !completion.Terminal { + continue + } + runner.cancel() + _ = <-runner.done + if completion.Success { + return completion, nil + } + return completion, fmt.Errorf("github workflow job failed: %s", completion.Message) + case <-ctx.Done(): + runner.cancel() + err := <-runner.done + if err != nil { + return last, fmt.Errorf("%s timed out waiting for GitHub job completion: %w: %s", filepath.Base(runner.path), ctx.Err(), runner.output()) + } + return last, ctx.Err() + } + } +} + +func (d *runnerDriver) observeGitHubJob(ctx context.Context, runnerName string, dispatchedAfter time.Time) (githubJobCompletion, error) { + runs, err := d.sidecar.workflowRuns(ctx, d.req.Repository, d.req.Workflow, dispatchedAfter) + if err != nil { + return githubJobCompletion{}, err + } + for _, run := range runs { + jobs, err := d.sidecar.workflowRunJobs(ctx, d.req.Repository, run.ID) + if err != nil { + return githubJobCompletion{}, err + } + for _, job := range jobs { + if strings.TrimSpace(job.RunnerName) != runnerName { + continue + } + completion := githubJobCompletion{ + WorkflowRunID: run.ID, + WorkflowJobID: job.ID, + Message: fmt.Sprintf("run=%d job=%d status=%s conclusion=%s runner=%s", run.ID, job.ID, job.Status, job.Conclusion, job.RunnerName), + } + if !strings.EqualFold(job.Status, "completed") && !strings.EqualFold(run.Status, "completed") { + return completion, nil + } + completion.Terminal = true + conclusion := strings.ToLower(strings.TrimSpace(job.Conclusion)) + if conclusion == "" { + conclusion = strings.ToLower(strings.TrimSpace(run.Conclusion)) + } + completion.Success = conclusion == "success" || conclusion == "succeeded" + return completion, nil + } + } + return githubJobCompletion{}, nil +} + func (d *runnerDriver) RemoveOrgRunner(ctx context.Context, organization string, runnerID int64) error { return d.sidecar.removeOrgRunner(ctx, organization, runnerID) } diff --git a/cmd/github-actions-runner-job/main_test.go b/cmd/github-actions-runner-job/main_test.go index 909960a..be87439 100644 --- a/cmd/github-actions-runner-job/main_test.go +++ b/cmd/github-actions-runner-job/main_test.go @@ -72,6 +72,9 @@ func TestT915CommandRunsDynamicProviderEnvelopeThroughSidecarAndRunner(t *testin case r.Method == http.MethodDelete && r.URL.Path == "/v1/actions/orgs/GoCodeAlone/runners/42": deleteCalls++ w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodGet && r.URL.Path == "/v1/actions/repos/GoCodeAlone/workflow-compute/workflows/dogfood.yml/runs": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"workflow_runs":[]}`)) default: t.Fatalf("unexpected sidecar request: %s %s", r.Method, r.URL.Path) } @@ -181,6 +184,9 @@ func TestT915CommandTreatsRunnerSuccessMarkerAsCompletion(t *testing.T) { case r.Method == http.MethodDelete && r.URL.Path == "/v1/actions/orgs/GoCodeAlone/runners/42": deleteCalls++ w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodGet && r.URL.Path == "/v1/actions/repos/GoCodeAlone/workflow-compute/workflows/dogfood.yml/runs": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"workflow_runs":[]}`)) default: t.Fatalf("unexpected sidecar request: %s %s", r.Method, r.URL.Path) } @@ -189,7 +195,7 @@ func TestT915CommandTreatsRunnerSuccessMarkerAsCompletion(t *testing.T) { 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") + 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\"\nexec sleep 1000\n") t.Chdir(workspace) t.Setenv("COMPUTE_GITHUB_RUNNER_PROVIDER_URL", sidecar.URL) t.Setenv("COMPUTE_GITHUB_RUNNER_PROVIDER_TOKEN", "provider-token") @@ -240,6 +246,164 @@ func TestT915CommandTreatsRunnerSuccessMarkerAsCompletion(t *testing.T) { } } +func TestT915CommandTreatsGitHubJobAPIAsCompletion(t *testing.T) { + oldPoll := githubJobPollInterval + githubJobPollInterval = 10 * time.Millisecond + t.Cleanup(func() { githubJobPollInterval = oldPoll }) + + var tokenCalls, dispatchCalls, runsCalls, jobsCalls, 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++ + 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.MethodGet && r.URL.Path == "/v1/actions/repos/GoCodeAlone/workflow-compute/workflows/dogfood.yml/runs": + runsCalls++ + if r.URL.Query().Get("created_after") == "" { + t.Fatalf("created_after query is required") + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"workflow_runs":[{"id":28449657934,"status":"completed","conclusion":"success","html_url":"https://github.com/GoCodeAlone/workflow-compute/actions/runs/28449657934"}]}`)) + case r.Method == http.MethodGet && r.URL.Path == "/v1/actions/repos/GoCodeAlone/workflow-compute/actions/runs/28449657934/jobs": + jobsCalls++ + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"jobs":[{"id":84308154551,"run_id":28449657934,"status":"completed","conclusion":"success","runner_name":"wfc-stg-ghp-linux-abcdef987249-543210f71ee4","labels":["self-hosted","linux","wfc-ghp-stg"]}]}`)) + 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.String()) + } + })) + 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\"\nexec sleep 1000\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 || runsCalls == 0 || jobsCalls == 0 || deleteCalls != 1 { + t.Fatalf("sidecar calls: token=%d dispatch=%d runs=%d jobs=%d delete=%d", tokenCalls, dispatchCalls, runsCalls, jobsCalls, deleteCalls) + } + proof := readFile(t, filepath.Join(workspace, "github-runner-proof.json")) + for _, want := range []string{"28449657934", "84308154551", "removed", "wfc-stg-ghp-linux-abcdef987249-543210f71ee4"} { + if !strings.Contains(proof, want) { + t.Fatalf("proof missing %q:\n%s", want, proof) + } + } +} + +func TestT915CommandRejectsFailedGitHubJobAPICompletion(t *testing.T) { + oldPoll := githubJobPollInterval + githubJobPollInterval = 10 * time.Millisecond + t.Cleanup(func() { githubJobPollInterval = oldPoll }) + + 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": + 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": + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodGet && r.URL.Path == "/v1/actions/repos/GoCodeAlone/workflow-compute/workflows/dogfood.yml/runs": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"workflow_runs":[{"id":28449657935,"status":"completed","conclusion":"failure"}]}`)) + case r.Method == http.MethodGet && r.URL.Path == "/v1/actions/repos/GoCodeAlone/workflow-compute/actions/runs/28449657935/jobs": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"jobs":[{"id":84308154552,"run_id":28449657935,"status":"completed","conclusion":"failure","runner_name":"wfc-stg-ghp-linux-abcdef987249-543210f71ee4"}]}`)) + case r.Method == http.MethodDelete && r.URL.Path == "/v1/actions/orgs/GoCodeAlone/runners/42": + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected sidecar request: %s %s", r.Method, r.URL.String()) + } + })) + 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\nexit 0\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 + err := runWithIO([]string{}, input, &stdout, &stderr) + if err == nil || !strings.Contains(err.Error(), "github workflow job failed") { + t.Fatalf("expected failed GitHub job error, got %v\nstderr:\n%s", err, stderr.String()) + } + proof := readFile(t, filepath.Join(workspace, "github-runner-proof.json")) + for _, want := range []string{"28449657935", "84308154552"} { + 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", diff --git a/internal/module_runner_provider.go b/internal/module_runner_provider.go index 3321b87..4690bd1 100644 --- a/internal/module_runner_provider.go +++ b/internal/module_runner_provider.go @@ -33,6 +33,8 @@ type GitHubRunnerClient interface { RemoveOrgRunner(ctx context.Context, organization string, runnerID int64, token string) error PreflightOrg(ctx context.Context, req GitHubRunnerProviderPreflightRequest, token string) (GitHubRunnerProviderPreflight, error) DispatchWorkflow(ctx context.Context, owner, repo, workflow, ref string, inputs map[string]string, token string) error + ListWorkflowRuns(ctx context.Context, owner, repo, workflow string, createdAfter time.Time, token string) ([]GitHubWorkflowRun, error) + ListWorkflowRunJobs(ctx context.Context, owner, repo string, runID int64, token string) ([]GitHubWorkflowJob, error) } type GitHubRunnerRegistrationToken struct { @@ -56,6 +58,29 @@ type GitHubRunnerProviderPreflight struct { SelfHostedAllowed bool `json:"self_hosted_allowed"` } +type GitHubWorkflowRun struct { + ID int64 `json:"id"` + Status string `json:"status"` + Conclusion string `json:"conclusion,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type GitHubWorkflowJob struct { + ID int64 `json:"id"` + RunID int64 `json:"run_id,omitempty"` + Status string `json:"status"` + Conclusion string `json:"conclusion,omitempty"` + RunnerID int64 `json:"runner_id,omitempty"` + RunnerName string `json:"runner_name,omitempty"` + RunnerGroupID int64 `json:"runner_group_id,omitempty"` + RunnerGroupName string `json:"runner_group_name,omitempty"` + Labels []string `json:"labels,omitempty"` + StartedAt string `json:"started_at,omitempty"` + CompletedAt string `json:"completed_at,omitempty"` +} + type httpGitHubRunnerClient struct { baseURL string httpClient *http.Client @@ -184,6 +209,55 @@ func (c *httpGitHubRunnerClient) DispatchWorkflow(ctx context.Context, owner, re return c.do(ctx, http.MethodPost, endpoint, body, token, http.StatusNoContent, nil) } +func (c *httpGitHubRunnerClient) ListWorkflowRuns(ctx context.Context, owner, repo, workflow string, createdAfter time.Time, token string) ([]GitHubWorkflowRun, error) { + endpoint := fmt.Sprintf("%s/repos/%s/%s/actions/workflows/%s/runs", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflow)) + query := url.Values{} + query.Set("event", "workflow_dispatch") + query.Set("per_page", "20") + if !createdAfter.IsZero() { + query.Set("created", ">="+createdAfter.UTC().Format(time.RFC3339)) + } + endpoint += "?" + query.Encode() + runs := make([]GitHubWorkflowRun, 0) + for endpoint != "" { + var out struct { + WorkflowRuns []GitHubWorkflowRun `json:"workflow_runs"` + } + headers, err := c.doRaw(ctx, http.MethodGet, endpoint, nil, token, http.StatusOK, &out) + if err != nil { + return nil, err + } + runs = append(runs, out.WorkflowRuns...) + endpoint = githubNextLink(headers.Get("Link")) + } + return runs, nil +} + +func (c *httpGitHubRunnerClient) ListWorkflowRunJobs(ctx context.Context, owner, repo string, runID int64, token string) ([]GitHubWorkflowJob, error) { + if runID <= 0 { + return nil, errors.New("run_id must be positive") + } + endpoint := fmt.Sprintf("%s/repos/%s/%s/actions/runs/%d/jobs?per_page=100", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), runID) + jobs := make([]GitHubWorkflowJob, 0) + for endpoint != "" { + var out struct { + Jobs []GitHubWorkflowJob `json:"jobs"` + } + headers, err := c.doRaw(ctx, http.MethodGet, endpoint, nil, token, http.StatusOK, &out) + if err != nil { + return nil, err + } + for _, job := range out.Jobs { + if job.RunID == 0 { + job.RunID = runID + } + jobs = append(jobs, job) + } + endpoint = githubNextLink(headers.Get("Link")) + } + return jobs, nil +} + func (c *httpGitHubRunnerClient) do(ctx context.Context, method, endpoint string, body any, token string, wantStatus int, out any) error { _, err := c.doRaw(ctx, method, endpoint, body, token, wantStatus, out) return err @@ -453,6 +527,47 @@ func (m *githubRunnerProviderModule) invokeMethod(ctx context.Context, method st return nil, err } return map[string]any{}, nil + case "workflow_runs": + owner, repo, repository, err := repositoryArg(args) + if err != nil { + return nil, err + } + if err := m.requireAllowedRepository(repository); err != nil { + return nil, err + } + workflow := stringArg(args, "workflow") + if workflow == "" { + return nil, errors.New("workflow is required") + } + createdAfter, err := timeArg(args, "created_after") + if err != nil { + return nil, err + } + runs, err := m.client.ListWorkflowRuns(ctx, owner, repo, workflow, createdAfter, m.config.Token) + if err != nil { + return nil, err + } + return map[string]any{"workflow_runs": runs}, nil + case "workflow_run_jobs": + owner, repo, repository, err := repositoryArg(args) + if err != nil { + return nil, err + } + if err := m.requireAllowedRepository(repository); err != nil { + return nil, err + } + runID, err := int64Arg(args, "run_id") + if err != nil { + return nil, err + } + if runID <= 0 { + return nil, errors.New("run_id must be positive") + } + jobs, err := m.client.ListWorkflowRunJobs(ctx, owner, repo, runID, m.config.Token) + if err != nil { + return nil, err + } + return map[string]any{"jobs": jobs}, nil case "ephemeral_runner_job": req, err := ephemeralRunnerJobRequestFromArgs(args) if err != nil { @@ -484,6 +599,8 @@ func (m *githubRunnerProviderModule) HTTPHandler() http.Handler { mux.HandleFunc("DELETE /v1/actions/orgs/{organization}/runners/{runner_id}", m.handleRemoveOrgRunner) mux.HandleFunc("POST /v1/actions/orgs/{organization}/runners/preflight", m.handleOrgPreflight) mux.HandleFunc("POST /v1/actions/repos/{owner}/{repo}/workflows/{workflow}/dispatches", m.handleDispatchWorkflow) + mux.HandleFunc("GET /v1/actions/repos/{owner}/{repo}/workflows/{workflow}/runs", m.handleWorkflowRuns) + mux.HandleFunc("GET /v1/actions/repos/{owner}/{repo}/actions/runs/{run_id}/jobs", m.handleWorkflowRunJobs) return mux } @@ -609,6 +726,59 @@ func (m *githubRunnerProviderModule) handleDispatchWorkflow(w http.ResponseWrite writeProviderResponse(w, http.StatusNoContent, out) } +func (m *githubRunnerProviderModule) handleWorkflowRuns(w http.ResponseWriter, r *http.Request) { + createdAfter, err := parseOptionalRFC3339Query(r.URL.Query().Get("created_after"), "created_after") + if err != nil { + writeProviderError(w, http.StatusBadRequest, err) + return + } + owner := r.PathValue("owner") + repo := r.PathValue("repo") + out, err := m.invokeMethod(r.Context(), "workflow_runs", map[string]any{ + "repository": owner + "/" + repo, + "workflow": r.PathValue("workflow"), + "created_after": createdAfter, + "provider_token": bearerToken(r), + }) + if err != nil { + writeProviderError(w, providerErrorStatus(err), err) + return + } + writeProviderResponse(w, http.StatusOK, out) +} + +func (m *githubRunnerProviderModule) handleWorkflowRunJobs(w http.ResponseWriter, r *http.Request) { + runID, err := strconv.ParseInt(r.PathValue("run_id"), 10, 64) + if err != nil || runID <= 0 { + writeProviderError(w, http.StatusBadRequest, errors.New("run_id must be positive")) + return + } + owner := r.PathValue("owner") + repo := r.PathValue("repo") + out, err := m.invokeMethod(r.Context(), "workflow_run_jobs", map[string]any{ + "repository": owner + "/" + repo, + "run_id": runID, + "provider_token": bearerToken(r), + }) + if err != nil { + writeProviderError(w, providerErrorStatus(err), err) + return + } + writeProviderResponse(w, http.StatusOK, out) +} + +func parseOptionalRFC3339Query(value, name string) (time.Time, error) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, nil + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{}, fmt.Errorf("%s must be RFC3339", name) + } + return parsed, nil +} + func bearerToken(r *http.Request) string { value := r.Header.Get("Authorization") token, ok := strings.CutPrefix(value, "Bearer ") @@ -1005,5 +1175,18 @@ func int64Arg(args map[string]any, key string) (int64, error) { } } +func timeArg(args map[string]any, key string) (time.Time, error) { + switch v := args[key].(type) { + case nil: + return time.Time{}, nil + case time.Time: + return v, nil + case string: + return parseOptionalRFC3339Query(v, key) + default: + return time.Time{}, fmt.Errorf("%s must be RFC3339", key) + } +} + var _ sdk.ModuleInstance = (*githubRunnerProviderModule)(nil) var _ sdk.ServiceInvoker = (*githubRunnerProviderModule)(nil) diff --git a/internal/runner_provider_test.go b/internal/runner_provider_test.go index 762d7c3..c6bf03a 100644 --- a/internal/runner_provider_test.go +++ b/internal/runner_provider_test.go @@ -133,6 +133,50 @@ func TestT915_GitHubRunnerClientDispatchesWorkflow(t *testing.T) { } } +func TestT915_GitHubRunnerClientListsWorkflowRunsAndJobs(t *testing.T) { + var sawCreatedFilter bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer github-token" { + t.Fatalf("authorization header: got %q", r.Header.Get("Authorization")) + } + switch { + case r.Method == http.MethodGet && r.URL.Path == "/repos/GoCodeAlone/workflow-compute/actions/workflows/dogfood.yml/runs": + if r.URL.Query().Get("event") != "workflow_dispatch" { + t.Fatalf("event query: %q", r.URL.RawQuery) + } + if strings.HasPrefix(r.URL.Query().Get("created"), ">=") { + sawCreatedFilter = true + } + writeRunnerProviderJSON(t, w, http.StatusOK, map[string]any{ + "workflow_runs": []map[string]any{{"id": 28449657934, "status": "completed", "conclusion": "success"}}, + }) + case r.Method == http.MethodGet && r.URL.Path == "/repos/GoCodeAlone/workflow-compute/actions/runs/28449657934/jobs": + writeRunnerProviderJSON(t, w, http.StatusOK, map[string]any{ + "jobs": []map[string]any{{"id": 84308154551, "status": "completed", "conclusion": "success", "runner_name": "wfc-stg-ghp-linux-260629fabb6f-1352329e8d5b"}}, + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + client := &httpGitHubRunnerClient{baseURL: server.URL, httpClient: server.Client()} + runs, err := client.ListWorkflowRuns(context.Background(), "GoCodeAlone", "workflow-compute", "dogfood.yml", time.Date(2026, 6, 30, 13, 53, 30, 0, time.UTC), "github-token") + if err != nil { + t.Fatalf("list workflow runs: %v", err) + } + if !sawCreatedFilter || len(runs) != 1 || runs[0].ID != 28449657934 { + t.Fatalf("runs=%+v sawCreatedFilter=%v", runs, sawCreatedFilter) + } + jobs, err := client.ListWorkflowRunJobs(context.Background(), "GoCodeAlone", "workflow-compute", 28449657934, "github-token") + if err != nil { + t.Fatalf("list workflow jobs: %v", err) + } + if len(jobs) != 1 || jobs[0].ID != 84308154551 || jobs[0].RunnerName == "" || jobs[0].RunID != 28449657934 { + t.Fatalf("jobs=%+v", jobs) + } +} + func TestT915_GitHubRunnerClientTreatsMissingRunnerAsRemoved(t *testing.T) { var paths []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -389,6 +433,69 @@ func TestT915_GitHubRunnerProviderHTTPDispatchesWorkflow(t *testing.T) { } } +func TestT915_GitHubRunnerProviderModuleListsWorkflowRunsFromRFC3339Arg(t *testing.T) { + createdAfter := "2026-06-30T13:52:30Z" + fake := &fakeRunnerClient{ + workflowRuns: []GitHubWorkflowRun{{ID: 28449657934, Status: "completed", Conclusion: "success"}}, + } + module, err := newGitHubRunnerProviderModule("provider", map[string]any{ + "token": "github-token", + "provider_token": "provider-token", + "repositories": []any{"GoCodeAlone/workflow-compute"}, + }, fake) + if err != nil { + t.Fatalf("module: %v", err) + } + + result, err := module.InvokeMethod("workflow_runs", map[string]any{ + "repository": "GoCodeAlone/workflow-compute", + "workflow": "dogfood-provider-target.yml", + "created_after": createdAfter, + "provider_token": "provider-token", + }) + if err != nil { + t.Fatalf("invoke workflow_runs: %v", err) + } + runs, ok := result["workflow_runs"].([]GitHubWorkflowRun) + if !ok || len(runs) != 1 || runs[0].ID != 28449657934 { + t.Fatalf("workflow_runs output: got %+v", result) + } + if fake.listRunsRepository != "GoCodeAlone/workflow-compute" || fake.listRunsWorkflow != "dogfood-provider-target.yml" { + t.Fatalf("list runs tracking: repo=%q workflow=%q", fake.listRunsRepository, fake.listRunsWorkflow) + } + wantCreatedAfter, err := time.Parse(time.RFC3339, createdAfter) + if err != nil { + t.Fatalf("parse test timestamp: %v", err) + } + if !fake.listRunsCreatedAfter.Equal(wantCreatedAfter) { + t.Fatalf("created_after: got %s want %s", fake.listRunsCreatedAfter.Format(time.RFC3339), wantCreatedAfter.Format(time.RFC3339)) + } + if fake.dispatchedRepository != "" || fake.dispatchedWorkflow != "" { + t.Fatalf("list runs mutated dispatch tracking: repo=%q workflow=%q", fake.dispatchedRepository, fake.dispatchedWorkflow) + } +} + +func TestT915_GitHubRunnerProviderModuleRejectsInvalidCreatedAfterArg(t *testing.T) { + module, err := newGitHubRunnerProviderModule("provider", map[string]any{ + "token": "github-token", + "provider_token": "provider-token", + "repositories": []any{"GoCodeAlone/workflow-compute"}, + }, &fakeRunnerClient{}) + if err != nil { + t.Fatalf("module: %v", err) + } + + _, err = module.InvokeMethod("workflow_runs", map[string]any{ + "repository": "GoCodeAlone/workflow-compute", + "workflow": "dogfood-provider-target.yml", + "created_after": 123, + "provider_token": "provider-token", + }) + if err == nil || !strings.Contains(err.Error(), "created_after must be RFC3339") { + t.Fatalf("expected created_after validation error, got %v", err) + } +} + func TestT593_GitHubRunnerProviderModuleRejectsRepositoryTokenWithoutRepositoryAllowlist(t *testing.T) { module, err := newGitHubRunnerProviderModule("provider", map[string]any{ "token": "github-token", @@ -595,6 +702,13 @@ type fakeRunnerClient struct { dispatchedWorkflow string dispatchedRef string dispatchedInputs map[string]string + listRunsRepository string + listRunsWorkflow string + listRunsCreatedAfter time.Time + listJobsRepository string + listJobsRunID int64 + workflowRuns []GitHubWorkflowRun + workflowJobs []GitHubWorkflowJob } type roundTripFunc func(*http.Request) (*http.Response, error) @@ -657,6 +771,24 @@ func (f *fakeRunnerClient) DispatchWorkflow(_ context.Context, owner, repo, work return nil } +func (f *fakeRunnerClient) ListWorkflowRuns(_ context.Context, owner, repo, workflow string, createdAfter time.Time, _ string) ([]GitHubWorkflowRun, error) { + f.listRunsRepository = owner + "/" + repo + f.listRunsWorkflow = workflow + f.listRunsCreatedAfter = createdAfter + return f.workflowRuns, nil +} + +func (f *fakeRunnerClient) ListWorkflowRunJobs(_ context.Context, owner, repo string, runID int64, _ string) ([]GitHubWorkflowJob, error) { + f.listJobsRepository = owner + "/" + repo + f.listJobsRunID = runID + for i := range f.workflowJobs { + if f.workflowJobs[i].RunID == 0 { + f.workflowJobs[i].RunID = runID + } + } + return f.workflowJobs, nil +} + func writeRunnerProviderJSON(t *testing.T, w http.ResponseWriter, status int, v any) { t.Helper() w.Header().Set("Content-Type", "application/json")