Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 28 additions & 10 deletions cmd/dependabot-action/docker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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"))
Expand All @@ -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 {
Expand Down
79 changes: 70 additions & 9 deletions cmd/dependabot-action/inputs.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
package main

import (
"encoding/json"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
)

type config struct {
apiURL string
apiDockerURL string
jobID string
jobToken string
credentialsToken string
Expand All @@ -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")
Expand Down
137 changes: 137 additions & 0 deletions cmd/dependabot-action/inputs_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package main

import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
Expand All @@ -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/",
Expand Down
6 changes: 5 additions & 1 deletion cmd/dependabot-action/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading