Skip to content
Draft
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
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.git
.env*
dependabot
dependabot-action
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,17 @@ jobs:
echo "gofmt failed, please run gofmt -w ."
exit 1
fi

action:
name: Docker Action integration
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod

- run: go test ./cmd/dependabot-action -run '^TestDockerAction$' -count=1 -timeout=10m
env:
DEPENDABOT_ACTION_INTEGRATION: '1'
29 changes: 29 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,35 @@ permissions:
packages: write

jobs:
action-image:
name: Publish Docker Action
if: ${{ !github.event.release.prerelease }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Build and push Action image
id: action_image
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
printf '%s' "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
docker buildx create --name action-builder --use
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag "ghcr.io/dependabot/cli-action:$RELEASE_TAG" \
--tag ghcr.io/dependabot/cli-action:v1 \
--metadata-file "$RUNNER_TEMP/action-image.json" \
--push .
echo "digest=$(jq -r '."containerimage.digest"' "$RUNNER_TEMP/action-image.json")" >> "$GITHUB_OUTPUT"

- uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ghcr.io/dependabot/cli-action
subject-digest: ${{ steps.action_image.outputs.digest }}
push-to-registry: true

releases-matrix:
name: Release Go Binary
runs-on: ubuntu-latest
Expand Down
14 changes: 14 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG TARGETOS
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w" -o /dependabot-action ./cmd/dependabot-action

FROM scratch
LABEL org.opencontainers.image.source="https://github.com/dependabot/cli"
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /dependabot-action /dependabot-action
ENTRYPOINT ["/dependabot-action"]
37 changes: 37 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Dependabot job
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
job-id:
description: Dependabot update job ID
required: true
job-token:
description: Job token (alternatively set GITHUB_DEPENDABOT_JOB_TOKEN)
required: false
credentials-token:
description: Credentials token (alternatively set GITHUB_DEPENDABOT_CRED_TOKEN)
required: false
updater-image:
description: Updater image override; otherwise selected from the job's package manager
required: false
proxy-image:
description: Proxy image override
required: false
pull-images:
description: Pull images before running; false uses images already on the runner
default: 'true'
timeout:
description: Maximum execution duration, excluding API bootstrap (Go duration)
default: 60m
volumes:
description: Bind mounts, one absolute Action-container source:destination[:ro] per line
required: false
updater-env:
description: Additional updater environment variables, one NAME=value per line
required: false
runs:
using: docker
image: docker://ghcr.io/dependabot/cli-action:v1
145 changes: 145 additions & 0 deletions cmd/dependabot-action/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package main

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"

"github.com/dependabot/cli/internal/model"
)

type jobClient struct {
config
http *http.Client
retryDelay time.Duration
}

func newJobClient(cfg config) *jobClient {
return &jobClient{
config: cfg,
http: &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
},
retryDelay: time.Second,
}
}

func (c *jobClient) details(ctx context.Context) (*model.Job, error) {
body, err := c.request(ctx, http.MethodGet, "details", c.jobToken, nil)
if err != nil {
return nil, err
}
var response struct {
Data struct {
Attributes *model.Job `json:"attributes"`
} `json:"data"`
}
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("invalid job details JSON")
}
job := response.Data.Attributes
if job == nil || job.PackageManager == "" || job.Source.Repo == "" {
return nil, fmt.Errorf("job details must include package-manager and source.repo")
}
return job, nil
}

func (c *jobClient) credentials(ctx context.Context) ([]model.Credential, error) {
body, err := c.request(ctx, http.MethodGet, "credentials", c.credentialsToken, nil)
if err != nil {
return nil, err
}
var response struct {
Data struct {
Attributes struct {
Credentials *[]model.Credential `json:"credentials"`
} `json:"attributes"`
} `json:"data"`
}
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("invalid credentials JSON")
}
if response.Data.Attributes.Credentials == nil {
return nil, fmt.Errorf("credentials response must include a credentials array")
}
creds := *response.Data.Attributes.Credentials
for _, cred := range creds {
if typ, ok := cred["type"].(string); !ok || typ == "" {
return nil, fmt.Errorf("each credential must include a type")
}
}
return creds, nil
}

func (c *jobClient) reportFailure(ctx context.Context, message string) error {
body, err := json.Marshal(map[string]any{"data": map[string]any{
"error-type": "actions_workflow_updater",
"error-details": map[string]string{"action-error": message},
}})
if err != nil {
return err
}
if _, err := c.request(ctx, http.MethodPost, "record_update_job_error", c.jobToken, body); err != nil {
return err
}
_, err = c.request(ctx, http.MethodPatch, "mark_as_processed", c.jobToken, []byte(`{"data":{"base-commit-sha":"unknown"}}`))
return err
}

func (c *jobClient) request(ctx context.Context, method, endpoint, token string, body []byte) ([]byte, error) {
url := strings.TrimRight(c.apiURL, "/") + "/update_jobs/" + c.jobID + "/" + endpoint
for attempt := 0; attempt < 4; attempt++ {
if err := ctx.Err(); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("%s: cannot construct request", endpoint)
}
req.Header.Set("Authorization", token)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "dependabot-cli-action")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := c.http.Do(req)
retry := false
var failure error
if err != nil {
failure = fmt.Errorf("%s: request failed: %w", endpoint, err)
retry = method == http.MethodGet
} else {
const maxResponseSize = 16 << 20
data, readErr := io.ReadAll(io.LimitReader(res.Body, maxResponseSize+1))
res.Body.Close()
if res.StatusCode == http.StatusOK || res.StatusCode == http.StatusNoContent {
if readErr != nil || len(data) > maxResponseSize {
return nil, fmt.Errorf("%s: failed to read complete response", endpoint)
}
return data, nil
}
failure = fmt.Errorf("%s: HTTP %d", endpoint, res.StatusCode)
// Retrying callbacks could record the same failure twice.
retry = method == http.MethodGet && (res.StatusCode == http.StatusTooManyRequests || res.StatusCode >= 500)
}
if !retry || attempt == 3 {
return nil, failure
}
timer := time.NewTimer(c.retryDelay * time.Duration(1<<attempt))
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
}
panic("unreachable")
}
117 changes: 117 additions & 0 deletions cmd/dependabot-action/api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package main

import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)

type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
return f(r)
}

func TestAPIRetriesTransportFailure(t *testing.T) {
api := newJobClient(config{apiURL: "https://dependabot.example", jobID: "42"})
api.retryDelay = 0
attempts := 0
api.http.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
attempts++
return nil, errors.New("connection refused")
})
if _, err := api.details(context.Background()); err == nil || attempts != 4 || !strings.Contains(err.Error(), "connection refused") {
t.Fatalf("transport failure: attempts %d, error %v", attempts, err)
}
}

func TestAPIRetries(t *testing.T) {
for _, status := range []int{http.StatusTooManyRequests, http.StatusServiceUnavailable, http.StatusUnauthorized} {
t.Run(fmt.Sprint(status), func(t *testing.T) {
attempts := 0
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts == 1 {
w.WriteHeader(status)
fmt.Fprint(w, "never-log-this-response")
return
}
fmt.Fprint(w, testJob)
}))
defer server.Close()
api := newJobClient(config{apiURL: server.URL, jobID: "42", jobToken: "secret"})
api.http = server.Client()
api.retryDelay = 0
_, err := api.details(context.Background())
if status == http.StatusUnauthorized {
if err == nil || attempts != 1 || strings.Contains(err.Error(), "never-log-this-response") {
t.Fatalf("authentication failure: attempts %d, error %v", attempts, err)
}
} else if err != nil || attempts != 2 {
t.Fatalf("transient failure: attempts %d, error %v", attempts, err)
}
})
}
}

func TestAPIRetryLimitAndCancellation(t *testing.T) {
attempts := 0
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer server.Close()
api := newJobClient(config{apiURL: server.URL, jobID: "42", jobToken: "secret"})
api.http = server.Client()
api.retryDelay = 0
if _, err := api.details(context.Background()); err == nil || attempts != 4 {
t.Fatalf("retry limit: attempts %d, error %v", attempts, err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := api.details(ctx); err == nil || attempts != 4 {
t.Fatalf("canceled request: attempts %d, error %v", attempts, err)
}
}

func TestAPIRejectsRedirects(t *testing.T) {
var requests int
target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
}))
defer target.Close()
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
}))
defer server.Close()
api := newJobClient(config{apiURL: server.URL, jobID: "42", jobToken: "secret"})
api.http.Transport = server.Client().Transport
api.http.Timeout = time.Second
if _, err := api.details(context.Background()); err == nil || requests != 0 {
t.Fatalf("redirect followed: requests %d, error %v", requests, err)
}
}

func TestAPIRejectsMalformedPayloads(t *testing.T) {
for _, body := range []string{`{`, `{}`, `{"data":{"attributes":null}}`, `{"data":{"attributes":{"source":{}}}}`} {
t.Run(body, func(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, body)
}))
defer server.Close()
api := newJobClient(config{apiURL: server.URL, jobID: "42"})
api.http = server.Client()
if _, err := api.details(context.Background()); err == nil {
t.Error("invalid details accepted")
}
if _, err := api.credentials(context.Background()); err == nil {
t.Error("invalid credentials accepted")
}
})
}
}
Loading
Loading