From 63c9d7c1c16397774147b30033a110eb85b8d61a Mon Sep 17 00:00:00 2001 From: "Brett V. Forsgren" Date: Mon, 26 Oct 2026 14:33:48 -0600 Subject: [PATCH] Support Dependabot dynamic event inputs in Docker action Read API URLs, job identifiers, images, and legacy tokens from the dynamic event payload while preserving explicit input precedence. Keep bootstrap and container API destinations separate and cover the hosted input path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02af271a-ceda-404a-af2d-ea4076dd7a62 --- action.yml | 17 ++-- cmd/dependabot-action/docker_test.go | 38 ++++++-- cmd/dependabot-action/inputs.go | 79 +++++++++++++-- cmd/dependabot-action/inputs_test.go | 137 +++++++++++++++++++++++++++ cmd/dependabot-action/main.go | 6 +- cmd/dependabot-action/main_test.go | 25 +++-- 6 files changed, 266 insertions(+), 36 deletions(-) diff --git a/action.yml b/action.yml index f15ca690..2ce53e10 100644 --- a/action.yml +++ b/action.yml @@ -3,19 +3,22 @@ description: Run an API-backed Dependabot job using the CLI's container runner author: Dependabot inputs: api-url: - description: Dependabot API base URL, including any path prefix - required: true + description: Dependabot API base URL, including any path prefix; required unless supplied by a Dependabot dynamic event + required: false + api-docker-url: + description: API base URL for updater and proxy containers; defaults to the dynamic event's Docker URL or api-url + required: false job-id: - description: Dependabot update job ID - required: true + description: Dependabot update job ID; required unless supplied by a Dependabot dynamic event + required: false job-token: - description: Job token (alternatively set GITHUB_DEPENDABOT_JOB_TOKEN) + description: Job token (alternatively set GITHUB_DEPENDABOT_JOB_TOKEN or use the Dependabot dynamic event) required: false credentials-token: - description: Credentials token (alternatively set GITHUB_DEPENDABOT_CRED_TOKEN) + description: Credentials token (alternatively set GITHUB_DEPENDABOT_CRED_TOKEN or use the Dependabot dynamic event) required: false updater-image: - description: Updater image override; otherwise selected from the job's package manager + description: Updater image override; otherwise taken from the dynamic event or selected from the job's package manager required: false proxy-image: description: Proxy image override diff --git a/cmd/dependabot-action/docker_test.go b/cmd/dependabot-action/docker_test.go index 8542a912..fa04316d 100644 --- a/cmd/dependabot-action/docker_test.go +++ b/cmd/dependabot-action/docker_test.go @@ -99,8 +99,16 @@ func TestDockerAction(t *testing.T) { defer server.Close() port := server.Listener.Addr().(*net.TCPAddr).Port - for _, failure := range []bool{false, true} { - t.Run(fmt.Sprintf("update/failure=%v", failure), func(t *testing.T) { + for _, tc := range []struct { + event bool + failure bool + }{ + {event: false, failure: false}, + {event: false, failure: true}, + {event: true, failure: false}, + {event: true, failure: true}, + } { + t.Run(fmt.Sprintf("update/event=%v/failure=%v", tc.event, tc.failure), func(t *testing.T) { dir := t.TempDir() if err := os.Chmod(dir, 0755); err != nil { t.Fatal(err) @@ -118,7 +126,7 @@ func TestDockerAction(t *testing.T) { mu.Lock() requests = nil mu.Unlock() - name := fmt.Sprintf("dependabot-action-test-%s-update-%v", suffix, failure) + name := fmt.Sprintf("dependabot-action-test-%s-event-%v-failure-%v", suffix, tc.event, tc.failure) t.Cleanup(func() { if out, err := exec.Command("docker", "rm", "-f", name).CombinedOutput(); err != nil { t.Logf("Action container cleanup: %v\n%s", err, out) @@ -130,22 +138,32 @@ func TestDockerAction(t *testing.T) { "--volume", "/var/run/docker.sock:/var/run/docker.sock", "--volume", dir + ":/github/workspace", "--env", "SSL_CERT_FILE=/github/workspace/ca.pem", - "--env", fmt.Sprintf("INPUT_API-URL=https://host.docker.internal:%d/api", port), - "--env", "INPUT_JOB-ID=42", "--env", "GITHUB_DEPENDABOT_JOB_TOKEN=job-secret", "--env", "GITHUB_DEPENDABOT_CRED_TOKEN=cred-secret", - "--env", "INPUT_UPDATER-IMAGE=" + updaterImage, "--env", "INPUT_PROXY-IMAGE=" + proxyImage, "--env", "INPUT_PULL-IMAGES=false", "--env", "INPUT_SKIP-IMAGE-VERIFICATION=true", "--env", "INPUT_TIMEOUT=1m", "--env", "INPUT_VOLUMES=/github/workspace/cache:/cache", - "--env", fmt.Sprintf("INPUT_UPDATER-ENV=CACHE_PATH=/cache\nFAIL=%v", failure), + "--env", fmt.Sprintf("INPUT_UPDATER-ENV=CACHE_PATH=/cache\nFAIL=%v", tc.failure), "--env", "TOKEN=must-not-be-expanded", - actionImage, } + if tc.event { + payload := fmt.Sprintf(`{"inputs":{"dependabotApiUrl":"https://host.docker.internal:%d/api","jobId":"42","updaterImage":%q}}`, port, updaterImage) + if err := os.WriteFile(filepath.Join(dir, "event.json"), []byte(payload), 0644); err != nil { + t.Fatal(err) + } + args = append(args, "--env", "GITHUB_EVENT_NAME=dynamic", "--env", "GITHUB_EVENT_PATH=/github/workspace/event.json") + } else { + args = append(args, + "--env", fmt.Sprintf("INPUT_API-URL=https://host.docker.internal:%d/api", port), + "--env", "INPUT_JOB-ID=42", + "--env", "INPUT_UPDATER-IMAGE="+updaterImage, + ) + } + args = append(args, actionImage) out, err := exec.CommandContext(t.Context(), "docker", args...).CombinedOutput() - if (err != nil) != failure { + if (err != nil) != tc.failure { t.Fatalf("Action exit = %v\n%s", err, out) } data, readErr := os.ReadFile(filepath.Join(cache, "result")) @@ -164,7 +182,7 @@ func TestDockerAction(t *testing.T) { got := strings.Join(requests, ",") mu.Unlock() want := "details,credentials" - if failure { + if tc.failure { want += ",record_update_job_error,mark_as_processed" } if got != want { diff --git a/cmd/dependabot-action/inputs.go b/cmd/dependabot-action/inputs.go index 6df68aa0..2f7a0934 100644 --- a/cmd/dependabot-action/inputs.go +++ b/cmd/dependabot-action/inputs.go @@ -1,8 +1,10 @@ package main import ( + "encoding/json" "fmt" "net/url" + "os" "strconv" "strings" "time" @@ -10,6 +12,7 @@ import ( type config struct { apiURL string + apiDockerURL string jobID string jobToken string credentialsToken string @@ -22,27 +25,85 @@ type config struct { updaterEnv []string } +type eventInputs struct { + APIURL string `json:"dependabotApiUrl"` + APIDockerURL string `json:"dependabotApiDockerUrl"` + JobID string `json:"jobId"` + JobToken string `json:"jobToken"` + CredentialsToken string `json:"credentialsToken"` + UpdaterImage string `json:"updaterImage"` +} + +func readEventInputs(getenv func(string) string) (eventInputs, error) { + if getenv("GITHUB_EVENT_NAME") != "dynamic" { + return eventInputs{}, nil + } + path := getenv("GITHUB_EVENT_PATH") + if path == "" { + return eventInputs{}, fmt.Errorf("GITHUB_EVENT_PATH is required for Dependabot dynamic events") + } + data, err := os.ReadFile(path) + if err != nil { + return eventInputs{}, fmt.Errorf("cannot read GITHUB_EVENT_PATH") + } + var event struct { + Inputs *eventInputs `json:"inputs"` + } + if err := json.Unmarshal(data, &event); err != nil { + return eventInputs{}, fmt.Errorf("invalid Dependabot event JSON") + } + if event.Inputs == nil { + return eventInputs{}, fmt.Errorf("Dependabot event must contain inputs") + } + return *event.Inputs, nil +} + func readConfig(getenv func(string) string) (config, error) { + event, err := readEventInputs(getenv) + if err != nil { + return config{}, err + } input := func(name string) string { return strings.TrimSpace(getenv("INPUT_" + strings.ToUpper(name))) } + inputOrEvent := func(name, fallback string) string { + if value := input(name); value != "" { + return value + } + return strings.TrimSpace(fallback) + } cfg := config{ - apiURL: strings.TrimRight(input("api-url"), "/"), jobID: input("job-id"), - jobToken: getenv("GITHUB_DEPENDABOT_JOB_TOKEN"), credentialsToken: getenv("GITHUB_DEPENDABOT_CRED_TOKEN"), - updaterImage: input("updater-image"), proxyImage: input("proxy-image"), + apiURL: strings.TrimRight(inputOrEvent("api-url", event.APIURL), "/"), + apiDockerURL: strings.TrimRight(inputOrEvent("api-docker-url", event.APIDockerURL), "/"), + jobID: inputOrEvent("job-id", event.JobID), + jobToken: getenv("GITHUB_DEPENDABOT_JOB_TOKEN"), credentialsToken: getenv("GITHUB_DEPENDABOT_CRED_TOKEN"), + updaterImage: inputOrEvent("updater-image", event.UpdaterImage), proxyImage: input("proxy-image"), pullImages: true, timeout: time.Hour, volumes: inputLines(input("volumes")), updaterEnv: inputLines(input("updater-env")), } + if cfg.apiDockerURL == "" { + cfg.apiDockerURL = cfg.apiURL + } if cfg.jobToken == "" { - cfg.jobToken = input("job-token") + cfg.jobToken = inputOrEvent("job-token", event.JobToken) } if cfg.credentialsToken == "" { - cfg.credentialsToken = input("credentials-token") + cfg.credentialsToken = inputOrEvent("credentials-token", event.CredentialsToken) } if cfg.jobToken == "" || cfg.credentialsToken == "" { - return config{}, fmt.Errorf("job-token and credentials-token are required (or their GITHUB_DEPENDABOT_* environment variables)") + return config{}, fmt.Errorf("job-token and credentials-token are required (or their GITHUB_DEPENDABOT_* environment variables or Dependabot dynamic event inputs)") + } + if cfg.apiURL == "" { + return config{}, fmt.Errorf("api-url is required: set the action input or Dependabot event inputs.dependabotApiUrl") + } + for _, endpoint := range []struct{ name, value string }{ + {"api-url", cfg.apiURL}, {"api-docker-url", cfg.apiDockerURL}, + } { + apiURL, err := url.Parse(endpoint.value) + if err != nil || apiURL.Scheme != "https" || apiURL.Host == "" || apiURL.User != nil || apiURL.RawQuery != "" || apiURL.Fragment != "" { + return config{}, fmt.Errorf("%s must be an HTTPS URL without credentials, query, or fragment", endpoint.name) + } } - apiURL, err := url.Parse(cfg.apiURL) - if err != nil || apiURL.Scheme != "https" || apiURL.Host == "" || apiURL.User != nil || apiURL.RawQuery != "" || apiURL.Fragment != "" { - return config{}, fmt.Errorf("api-url must be an HTTPS URL without credentials, query, or fragment") + if cfg.jobID == "" { + return config{}, fmt.Errorf("job-id is required: set the action input or Dependabot event inputs.jobId") } if id, err := strconv.ParseUint(cfg.jobID, 10, 64); err != nil || id == 0 { return config{}, fmt.Errorf("job-id must be a positive integer") diff --git a/cmd/dependabot-action/inputs_test.go b/cmd/dependabot-action/inputs_test.go index edc36f47..b1fde41c 100644 --- a/cmd/dependabot-action/inputs_test.go +++ b/cmd/dependabot-action/inputs_test.go @@ -1,6 +1,8 @@ package main import ( + "os" + "path/filepath" "reflect" "strings" "testing" @@ -10,6 +12,141 @@ import ( "github.com/docker/docker/api/types/mount" ) +func writeEventFile(t *testing.T, payload string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "event.json") + if err := os.WriteFile(path, []byte(payload), 0600); err != nil { + t.Fatal(err) + } + return path +} + +func TestReadConfigDynamicEvent(t *testing.T) { + path := writeEventFile(t, `{"inputs":{ + "dependabotApiUrl":"https://dependabot.example/api/", + "jobId":"42","jobToken":"event-job","credentialsToken":"event-cred", + "updaterImage":"event:updater" + }}`) + dockerPath := writeEventFile(t, `{"inputs":{ + "dependabotApiUrl":"https://dependabot.example/api/", + "dependabotApiDockerUrl":"https://event-docker.example/api/", + "jobId":"42","jobToken":"event-job","credentialsToken":"event-cred", + "updaterImage":"event:updater" + }}`) + for _, tc := range []struct { + name string + overrides map[string]string + want config + }{ + { + name: "event inputs", + want: config{ + apiURL: "https://dependabot.example/api", apiDockerURL: "https://dependabot.example/api", + jobID: "42", jobToken: "event-job", credentialsToken: "event-cred", updaterImage: "event:updater", + }, + }, + { + name: "explicit inputs", + overrides: map[string]string{ + "INPUT_API-URL": "https://override.example/prefix/", "INPUT_JOB-ID": "99", + "INPUT_JOB-TOKEN": "input-job", "INPUT_CREDENTIALS-TOKEN": "input-cred", + "INPUT_UPDATER-IMAGE": "input:updater", + }, + want: config{ + apiURL: "https://override.example/prefix", apiDockerURL: "https://override.example/prefix", + jobID: "99", jobToken: "input-job", credentialsToken: "input-cred", updaterImage: "input:updater", + }, + }, + { + name: "environment tokens", + overrides: map[string]string{ + "GITHUB_DEPENDABOT_JOB_TOKEN": "env-job", "GITHUB_DEPENDABOT_CRED_TOKEN": "env-cred", + "INPUT_JOB-TOKEN": "input-job", "INPUT_CREDENTIALS-TOKEN": "input-cred", + }, + want: config{ + apiURL: "https://dependabot.example/api", apiDockerURL: "https://dependabot.example/api", + jobID: "42", jobToken: "env-job", credentialsToken: "env-cred", updaterImage: "event:updater", + }, + }, + { + name: "event Docker URL", + overrides: map[string]string{ + "GITHUB_EVENT_PATH": dockerPath, + }, + want: config{ + apiURL: "https://dependabot.example/api", apiDockerURL: "https://event-docker.example/api", + jobID: "42", jobToken: "event-job", credentialsToken: "event-cred", updaterImage: "event:updater", + }, + }, + { + name: "explicit Docker URL", + overrides: map[string]string{ + "GITHUB_EVENT_PATH": dockerPath, + "INPUT_API-DOCKER-URL": "https://docker.example/prefix/", + }, + want: config{ + apiURL: "https://dependabot.example/api", apiDockerURL: "https://docker.example/prefix", + jobID: "42", jobToken: "event-job", credentialsToken: "event-cred", updaterImage: "event:updater", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + env := map[string]string{"GITHUB_EVENT_NAME": "dynamic", "GITHUB_EVENT_PATH": path} + for key, value := range tc.overrides { + env[key] = value + } + got, err := readConfig(func(key string) string { return env[key] }) + tc.want.pullImages = true + tc.want.timeout = time.Hour + if err != nil || !reflect.DeepEqual(got, tc.want) { + t.Fatalf("readConfig() = %#v, %v; want %#v", got, err, tc.want) + } + }) + } +} + +func TestReadConfigEventErrors(t *testing.T) { + for _, tc := range []struct { + name string + payload string + eventName string + overrides map[string]string + want string + }{ + {name: "missing path", eventName: "dynamic", want: "GITHUB_EVENT_PATH is required"}, + {name: "unreadable path", eventName: "dynamic", overrides: map[string]string{"GITHUB_EVENT_PATH": filepath.Join(t.TempDir(), "missing.json")}, want: "cannot read GITHUB_EVENT_PATH"}, + {name: "malformed JSON", eventName: "dynamic", payload: `{"inputs":{"jobToken":"do-not-log-secret"`, want: "invalid Dependabot event JSON"}, + {name: "missing inputs", eventName: "dynamic", payload: `{}`, want: "Dependabot event must contain inputs"}, + {name: "null inputs", eventName: "dynamic", payload: `{"inputs":null}`, want: "Dependabot event must contain inputs"}, + {name: "wrong input type", eventName: "dynamic", payload: `{"inputs":{"jobId":[]}}`, want: "invalid Dependabot event JSON"}, + {name: "missing URL", eventName: "dynamic", payload: `{"inputs":{"jobId":"42"}}`, want: "api-url is required"}, + {name: "missing job ID", eventName: "dynamic", payload: `{"inputs":{"dependabotApiUrl":"https://example.com"}}`, want: "job-id is required"}, + {name: "other event ignored", eventName: "workflow_dispatch", payload: `{"inputs":{"dependabotApiUrl":"https://example.com","jobId":"42"}}`, want: "api-url is required"}, + {name: "invalid explicit URL", eventName: "dynamic", payload: `{"inputs":{"dependabotApiUrl":"https://example.com","jobId":"42"}}`, overrides: map[string]string{"INPUT_API-URL": "http://invalid.example"}, want: "api-url must be an HTTPS URL"}, + {name: "invalid event URL", eventName: "dynamic", payload: `{"inputs":{"dependabotApiUrl":"https://user:do-not-log-secret@example.com","jobId":"42"}}`, want: "api-url must be an HTTPS URL"}, + {name: "query in event URL", eventName: "dynamic", payload: `{"inputs":{"dependabotApiUrl":"https://example.com?token=do-not-log-secret","jobId":"42"}}`, want: "api-url must be an HTTPS URL"}, + {name: "fragment in event URL", eventName: "dynamic", payload: `{"inputs":{"dependabotApiUrl":"https://example.com#do-not-log-secret","jobId":"42"}}`, want: "api-url must be an HTTPS URL"}, + {name: "invalid Docker URL", eventName: "dynamic", payload: `{"inputs":{"dependabotApiUrl":"https://example.com","dependabotApiDockerUrl":"http://invalid.example","jobId":"42"}}`, want: "api-docker-url must be an HTTPS URL"}, + } { + t.Run(tc.name, func(t *testing.T) { + env := map[string]string{ + "GITHUB_EVENT_NAME": tc.eventName, + "GITHUB_DEPENDABOT_JOB_TOKEN": "job", "GITHUB_DEPENDABOT_CRED_TOKEN": "cred", + } + if tc.payload != "" { + env["GITHUB_EVENT_PATH"] = writeEventFile(t, tc.payload) + } + for key, value := range tc.overrides { + env[key] = value + } + _, err := readConfig(func(key string) string { return env[key] }) + if err == nil || !strings.Contains(err.Error(), tc.want) || strings.Contains(err.Error(), "do-not-log-secret") { + t.Fatalf("readConfig() error = %v; want %q without secrets", err, tc.want) + } + }) + } +} + func TestReadConfig(t *testing.T) { env := map[string]string{ "INPUT_API-URL": "https://dependabot.example/api/", diff --git a/cmd/dependabot-action/main.go b/cmd/dependabot-action/main.go index eb479962..d2279570 100644 --- a/cmd/dependabot-action/main.go +++ b/cmd/dependabot-action/main.go @@ -52,9 +52,13 @@ func run(ctx context.Context, cfg config, api *jobClient, output io.Writer, runn err = os.Setenv("DEPENDABOT_JOB_ID", cfg.jobID) } if err == nil { + apiURL := cfg.apiDockerURL + if apiURL == "" { + apiURL = cfg.apiURL + } err = runner(ctx, infra.RunParams{ Job: job, Creds: creds, CredentialsResolved: true, - ApiUrl: cfg.apiURL, UpdaterImage: cfg.updaterImage, ProxyImage: cfg.proxyImage, + ApiUrl: apiURL, UpdaterImage: cfg.updaterImage, ProxyImage: cfg.proxyImage, StorageImage: infra.StorageImageName, PullImages: cfg.pullImages, SkipImageVerification: cfg.skipImageVerification, Timeout: cfg.timeout, Volumes: cfg.volumes, UpdaterEnvironmentVariables: cfg.updaterEnv, diff --git a/cmd/dependabot-action/main_test.go b/cmd/dependabot-action/main_test.go index acd661e5..b87d0773 100644 --- a/cmd/dependabot-action/main_test.go +++ b/cmd/dependabot-action/main_test.go @@ -13,7 +13,6 @@ import ( "reflect" "strings" "testing" - "time" "github.com/dependabot/cli/internal/infra" "github.com/dependabot/cli/internal/model" @@ -49,17 +48,25 @@ func TestRunHostedJob(t *testing.T) { } })) defer server.Close() - cfg := config{ - apiURL: server.URL + "/prefix", jobID: "42", jobToken: "job-secret", credentialsToken: "cred-secret", - updaterImage: "custom:job", proxyImage: "custom:proxy", pullImages: false, - skipImageVerification: true, - timeout: 3 * time.Hour, volumes: []string{"/github/workspace/cache:/cache"}, - updaterEnv: []string{"CUSTOM_CACHE_PATH=/cache"}, + eventPath := writeEventFile(t, fmt.Sprintf(`{"inputs":{ + "dependabotApiUrl":%q,"dependabotApiDockerUrl":"https://updater.example/prefix", + "jobId":"42","updaterImage":"custom:job" + }}`, server.URL+"/prefix")) + env := map[string]string{ + "GITHUB_EVENT_NAME": "dynamic", "GITHUB_EVENT_PATH": eventPath, + "GITHUB_DEPENDABOT_JOB_TOKEN": "job-secret", "GITHUB_DEPENDABOT_CRED_TOKEN": "cred-secret", + "INPUT_PROXY-IMAGE": "custom:proxy", "INPUT_PULL-IMAGES": "false", + "INPUT_SKIP-IMAGE-VERIFICATION": "true", "INPUT_TIMEOUT": "3h", + "INPUT_VOLUMES": "/github/workspace/cache:/cache", "INPUT_UPDATER-ENV": "CUSTOM_CACHE_PATH=/cache", + } + cfg, err := readConfig(func(key string) string { return env[key] }) + if err != nil { + t.Fatal(err) } api := newJobClient(cfg) api.http = server.Client() called := false - err := run(context.Background(), cfg, api, &output, func(ctx context.Context, params infra.RunParams) error { + err = run(context.Background(), cfg, api, &output, func(ctx context.Context, params infra.RunParams) error { called = true if len(paths) != 2 { t.Fatal("runner invoked before fetching both responses") @@ -75,7 +82,7 @@ func TestRunHostedJob(t *testing.T) { t.Errorf("%q not masked before execution", secret) } } - if params.ApiUrl != cfg.apiURL || params.UpdaterImage != cfg.updaterImage || params.ProxyImage != cfg.proxyImage || + if params.ApiUrl != cfg.apiDockerURL || params.UpdaterImage != cfg.updaterImage || params.ProxyImage != cfg.proxyImage || params.PullImages || !params.SkipImageVerification || params.Timeout != cfg.timeout || params.StorageImage != infra.StorageImageName || !reflect.DeepEqual(params.Volumes, cfg.volumes) || !reflect.DeepEqual(params.UpdaterEnvironmentVariables, cfg.updaterEnv) { t.Fatalf("incorrect runner options: %+v", params)