From 729b947986cf2beb19aadeca245b6a0dca10976c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 6 Jul 2026 07:45:16 -0400 Subject: [PATCH] fix: handle unreported runner completion --- cmd/github-actions-runner-job/main.go | 27 +++++ cmd/github-actions-runner-job/main_test.go | 122 +++++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/cmd/github-actions-runner-job/main.go b/cmd/github-actions-runner-job/main.go index 29590c5..ac0af56 100644 --- a/cmd/github-actions-runner-job/main.go +++ b/cmd/github-actions-runner-job/main.go @@ -492,6 +492,8 @@ func (d *runnerDriver) observeGitHubJob(ctx context.Context, runnerName string, return githubJobCompletion{}, err } var unassigned githubJobCompletion + var terminalUnreported githubJobCompletion + allowTerminalUnreported := len(runs) == 1 for _, run := range runs { jobs, err := d.sidecar.workflowRunJobs(ctx, d.req.Repository, run.ID) if err != nil { @@ -499,6 +501,8 @@ func (d *runnerDriver) observeGitHubJob(ctx context.Context, runnerName string, } for _, job := range jobs { jobRunner := strings.TrimSpace(job.RunnerName) + runTerminal := strings.EqualFold(run.Status, "completed") + jobTerminal := strings.EqualFold(job.Status, "completed") if jobRunner == "" && unassigned.WorkflowRunID == 0 && d.jobLabelsMatchRunner(job.Labels, runnerName) && !strings.EqualFold(job.Status, "completed") && !strings.EqualFold(run.Status, "completed") { unassigned = githubJobCompletion{ WorkflowRunID: run.ID, @@ -508,6 +512,26 @@ func (d *runnerDriver) observeGitHubJob(ctx context.Context, runnerName string, } continue } + if allowTerminalUnreported && jobRunner == "" && terminalUnreported.WorkflowRunID == 0 && (runTerminal || jobTerminal) { + conclusion := strings.ToLower(strings.TrimSpace(job.Conclusion)) + if conclusion == "" { + conclusion = strings.ToLower(strings.TrimSpace(run.Conclusion)) + } + if conclusion == "skipped" { + continue + } + completion := githubJobCompletion{ + WorkflowRunID: run.ID, + WorkflowJobID: job.ID, + WorkflowJobStatus: job.Status, + Assigned: true, + Terminal: true, + Message: fmt.Sprintf("run=%d job=%d status=%s conclusion=%s runner=unreported", run.ID, job.ID, job.Status, job.Conclusion), + } + completion.Success = conclusion == "success" || conclusion == "succeeded" + terminalUnreported = completion + continue + } if jobRunner != runnerName { continue } @@ -530,6 +554,9 @@ func (d *runnerDriver) observeGitHubJob(ctx context.Context, runnerName string, return completion, nil } } + if terminalUnreported.WorkflowRunID != 0 { + return terminalUnreported, nil + } return unassigned, nil } diff --git a/cmd/github-actions-runner-job/main_test.go b/cmd/github-actions-runner-job/main_test.go index b4c9f56..891ef97 100644 --- a/cmd/github-actions-runner-job/main_test.go +++ b/cmd/github-actions-runner-job/main_test.go @@ -416,6 +416,128 @@ func TestT915WaitForGitHubCompletionDoesNotBlockOnRunnerShutdownAfterTerminalSuc } } +func TestT915WaitForGitHubCompletionAcceptsBlankRunnerNameAfterTerminalSuccess(t *testing.T) { + oldPoll := githubJobPollInterval + githubJobPollInterval = 10 * time.Millisecond + oldShutdownGrace := githubRunnerShutdownGrace + githubRunnerShutdownGrace = 10 * time.Millisecond + t.Cleanup(func() { + githubJobPollInterval = oldPoll + githubRunnerShutdownGrace = oldShutdownGrace + }) + + var canceled bool + 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.MethodGet && r.URL.Path == "/v1/actions/repos/GoCodeAlone/workflow-compute/workflows/dogfood.yml/runs": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"workflow_runs":[{"id":28788166953,"status":"completed","conclusion":"success"}]}`)) + case r.Method == http.MethodGet && r.URL.Path == "/v1/actions/repos/GoCodeAlone/workflow-compute/actions/runs/28788166953/jobs": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"jobs":[{"id":85359800000,"run_id":28788166953,"status":"completed","conclusion":"skipped","runner_name":""},{"id":85359812345,"run_id":28788166953,"status":"completed","conclusion":"success","runner_name":""}]}`)) + default: + t.Fatalf("unexpected sidecar request: %s %s", r.Method, r.URL.String()) + } + })) + t.Cleanup(sidecar.Close) + + driver := &runnerDriver{ + req: internal.EphemeralRunnerJobRequest{ + Repository: "GoCodeAlone/workflow-compute", + Workflow: "dogfood.yml", + }, + sidecar: &providerSidecarClient{ + baseURL: sidecar.URL, + token: "provider-token", + http: sidecar.Client(), + }, + } + runner := &runningCommand{ + path: "run.sh", + cancel: func() { + canceled = true + }, + result: make(chan runnerCompletion, 1), + done: make(chan error), + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + done := make(chan error, 1) + go func() { + completion, err := driver.waitForGitHubCompletion(ctx, runner, "wfc-stg-ghp-linux-260629fabb6f-12640z85f6ed", time.Now().UTC().Add(-time.Minute)) + if err != nil { + done <- err + return + } + if !completion.Success || !completion.Terminal || completion.WorkflowRunID != 28788166953 || completion.WorkflowJobID != 85359812345 { + done <- fmt.Errorf("completion = %+v", completion) + return + } + if !strings.Contains(completion.Message, "runner=unreported") { + done <- fmt.Errorf("completion message did not record blank-runner fallback: %s", completion.Message) + return + } + done <- nil + }() + + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(150 * time.Millisecond): + t.Fatal("waitForGitHubCompletion blocked when GitHub job API omitted runner_name") + } + if !canceled { + t.Fatal("runner was not asked to stop") + } +} + +func TestT915ObserveGitHubJobDoesNotUseBlankRunnerFallbackAcrossMultipleRuns(t *testing.T) { + 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.MethodGet && r.URL.Path == "/v1/actions/repos/GoCodeAlone/workflow-compute/workflows/dogfood.yml/runs": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"workflow_runs":[{"id":28788166953,"status":"completed","conclusion":"success"},{"id":28788166954,"status":"completed","conclusion":"success"}]}`)) + case r.Method == http.MethodGet && r.URL.Path == "/v1/actions/repos/GoCodeAlone/workflow-compute/actions/runs/28788166953/jobs": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"jobs":[{"id":85359812345,"run_id":28788166953,"status":"completed","conclusion":"success","runner_name":""}]}`)) + case r.Method == http.MethodGet && r.URL.Path == "/v1/actions/repos/GoCodeAlone/workflow-compute/actions/runs/28788166954/jobs": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"jobs":[{"id":85359812346,"run_id":28788166954,"status":"completed","conclusion":"success","runner_name":""}]}`)) + default: + t.Fatalf("unexpected sidecar request: %s %s", r.Method, r.URL.String()) + } + })) + t.Cleanup(sidecar.Close) + + driver := &runnerDriver{ + req: internal.EphemeralRunnerJobRequest{ + Repository: "GoCodeAlone/workflow-compute", + Workflow: "dogfood.yml", + }, + sidecar: &providerSidecarClient{ + baseURL: sidecar.URL, + token: "provider-token", + http: sidecar.Client(), + }, + } + completion, err := driver.observeGitHubJob(context.Background(), "wfc-stg-ghp-linux-260629fabb6f-12640z85f6ed", time.Now().UTC().Add(-time.Minute)) + if err != nil { + t.Fatalf("observe GitHub job: %v", err) + } + if completion.WorkflowRunID != 0 { + t.Fatalf("blank runner fallback must fail closed across multiple runs, got %+v", completion) + } +} + func TestT915WaitForGitHubCompletionDoesNotBlockOnRunnerShutdownAfterTimeout(t *testing.T) { oldPoll := githubJobPollInterval githubJobPollInterval = time.Hour