diff --git a/.github/workflows/pre-submit.yml b/.github/workflows/pre-submit.yml
new file mode 100644
index 0000000000..91f6e46d66
--- /dev/null
+++ b/.github/workflows/pre-submit.yml
@@ -0,0 +1,25 @@
+name: Pre submits
+on: [pull_request, workflow_dispatch]
+
+permissions: read-all
+
+jobs:
+  pre-submit:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 # v2.3.4
+
+      - name: setup-go
+        uses: actions/setup-go@bfdd3570ce990073878bf10f6b2d79082de49492 # v2.2.0
+        with:
+          go-version: "1.18"
+
+      - name: Run tests
+        run: |
+          set -euo pipefail
+
+          # Download dependencies.
+          go mod vendor
+          # Test.
+          go test -mod=vendor -v ./...
diff --git a/github/oidc.go b/github/oidc.go
index 36995e44d5..0d27c1336a 100644
--- a/github/oidc.go
+++ b/github/oidc.go
@@ -17,6 +17,7 @@ package github
 import (
 	"encoding/base64"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"net/http"
 	"os"
@@ -34,24 +35,32 @@ type OIDCToken struct {
 	JobWorkflowRef string `json:"job_workflow_ref"`
 }
 
+var (
+	errURLEnvKeyEmpty   = fmt.Errorf("%q env var is empty", requestURLEnvKey)
+	errResponseJSON     = errors.New("invalid response JSON")
+	errInvalidToken     = errors.New("invalid JWT token")
+	errInvalidTokenB64  = errors.New("invalid JWT token base64")
+	errInvalidTokenJSON = errors.New("invalid JWT token JSON")
+)
+
 // RequestOIDCToken requests an OIDC token from Github's provider and returns
 // the token.
 func RequestOIDCToken(audience string) (*OIDCToken, error) {
 	urlKey := os.Getenv(requestURLEnvKey)
 	if urlKey == "" {
-		return nil, fmt.Errorf("requestURLEnvKey is empty")
+		return nil, errURLEnvKeyEmpty
 	}
 
 	url := urlKey + "&audience=" + audience
 	req, err := http.NewRequest("GET", url, nil)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("creating request: %w", err)
 	}
 
 	req.Header.Add("Authorization", "bearer "+os.Getenv(requestTokenEnvKey))
 	resp, err := http.DefaultClient.Do(req)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("request: %w", err)
 	}
 	defer resp.Body.Close()
 
@@ -62,13 +71,13 @@ func RequestOIDCToken(audience string) (*OIDCToken, error) {
 	// Extract the value from JSON payload.
 	decoder := json.NewDecoder(resp.Body)
 	if err := decoder.Decode(&payload); err != nil {
-		return nil, err
+		return nil, errResponseJSON
 	}
 
 	// This is a JWT token with 3 parts.
 	parts := strings.Split(payload.Value, ".")
 	if len(parts) != 3 {
-		return nil, fmt.Errorf("invalid jwt token: found %d parts", len(parts))
+		return nil, errInvalidToken
 	}
 
 	content := parts[1]
@@ -76,12 +85,12 @@ func RequestOIDCToken(audience string) (*OIDCToken, error) {
 	// Base64-decode the content.
 	token, err := base64.RawURLEncoding.DecodeString(content)
 	if err != nil {
-		return nil, fmt.Errorf("base64.RawURLEncoding.DecodeString: %w", err)
+		return nil, errInvalidTokenB64
 	}
 
 	var oidc OIDCToken
 	if err := json.Unmarshal(token, &oidc); err != nil {
-		return nil, fmt.Errorf("json.Unmarshal: %w", err)
+		return nil, errInvalidTokenJSON
 	}
 
 	return &oidc, nil
diff --git a/github/oidc_test.go b/github/oidc_test.go
new file mode 100644
index 0000000000..c0e689218b
--- /dev/null
+++ b/github/oidc_test.go
@@ -0,0 +1,71 @@
+package github
+
+import (
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+)
+
+func TestRequestOIDCToken(t *testing.T) {
+	testCases := []struct {
+		name     string
+		audience string
+		expected *OIDCToken
+		raw      string
+		err      error
+	}{
+		{
+			name:     "basic token",
+			audience: "hoge",
+			expected: &OIDCToken{JobWorkflowRef: "hoge"},
+		},
+		{
+			name:     "invalid response",
+			audience: "hoge",
+			raw:      `not json`,
+			err:      errResponseJSON,
+		},
+		{
+			name:     "invalid parts",
+			audience: "hoge",
+			raw:      `{"value": "part1"}`,
+			err:      errInvalidToken,
+		},
+		{
+			name:     "invalid base64",
+			audience: "hoge",
+			raw:      `{"value": "part1.part2.part3"}`,
+			err:      errInvalidTokenB64,
+		},
+		{
+			name:     "invalid json",
+			audience: "hoge",
+			raw:      fmt.Sprintf(`{"value": "part1.%s.part3"}`, base64.RawURLEncoding.EncodeToString([]byte("not json"))),
+			err:      errInvalidTokenJSON,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			if tc.expected != nil {
+				_, stop := NewTestOIDCServer(tc.expected)
+				defer stop()
+			} else {
+				_, stop := newRawTestOIDCServer(tc.raw)
+				defer stop()
+			}
+
+			token, err := RequestOIDCToken(tc.audience)
+			if !errors.Is(err, tc.err) {
+				t.Fatalf("unexpected error: %v", cmp.Diff(err, tc.err, cmpopts.EquateErrors()))
+			}
+			if want, got := tc.expected, token; !cmp.Equal(want, got) {
+				t.Errorf("unexpected workflow ref\nwant: %#v\ngot:  %#v\ndiff: %#v", want, got, cmp.Diff(want, got))
+			}
+		})
+	}
+}
diff --git a/github/oidctest.go b/github/oidctest.go
new file mode 100644
index 0000000000..b1b5cf09fb
--- /dev/null
+++ b/github/oidctest.go
@@ -0,0 +1,41 @@
+package github
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"os"
+)
+
+// NewTestOIDCServer returns an httptest.Server that can be used as the OIDC
+// server in tests and a cleanup function that can be used to stop and clean up
+// the server. The server returns the given token when queried.
+func NewTestOIDCServer(t *OIDCToken) (*httptest.Server, func()) {
+	b, err := json.Marshal(t)
+	if err != nil {
+		panic(err)
+	}
+
+	rawResponse := fmt.Sprintf(`{"value": "part1.%s.part3"}`, base64.RawURLEncoding.EncodeToString(b))
+	return newRawTestOIDCServer(rawResponse)
+}
+
+func newRawTestOIDCServer(raw string) (*httptest.Server, func()) {
+	s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		// Respond with a very basic 3-part JWT token.
+		fmt.Fprintln(w, raw)
+	}))
+	oldEnv, ok := os.LookupEnv(requestURLEnvKey)
+	// NOTE: httptest.Server.URL has no trailing slash.
+	os.Setenv(requestURLEnvKey, s.URL+"/")
+	return s, func() {
+		s.Close()
+		if ok {
+			os.Setenv(requestURLEnvKey, oldEnv)
+		} else {
+			os.Unsetenv(requestURLEnvKey)
+		}
+	}
+}
diff --git a/slsa/provenance_test.go b/slsa/provenance_test.go
new file mode 100644
index 0000000000..2de858f49e
--- /dev/null
+++ b/slsa/provenance_test.go
@@ -0,0 +1,51 @@
+package slsa
+
+import (
+	"reflect"
+	"testing"
+
+	intoto "github.com/in-toto/in-toto-golang/in_toto"
+	slsa "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2"
+
+	"github.com/slsa-framework/slsa-github-generator/github"
+)
+
+func TestHostedActionsProvenance(t *testing.T) {
+	testCases := []struct {
+		name     string
+		r        WorkflowRun
+		expected *intoto.ProvenanceStatement
+	}{
+		{
+			name: "empty",
+			r:    WorkflowRun{},
+			expected: &intoto.ProvenanceStatement{
+				StatementHeader: intoto.StatementHeader{
+					Type:          intoto.StatementInTotoV01,
+					PredicateType: slsa.PredicateSLSAProvenance,
+				},
+				Predicate: slsa.ProvenancePredicate{
+					Builder: slsa.ProvenanceBuilder{
+						ID: GithubHostedActionsBuilderID,
+					},
+					Metadata: &slsa.ProvenanceMetadata{},
+				},
+			},
+		},
+	}
+
+	for _, tc := range testCases {
+		_, stop := github.NewTestOIDCServer(nil)
+		defer stop()
+
+		t.Run(tc.name, func(t *testing.T) {
+			if p, err := HostedActionsProvenance(tc.r); err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			} else {
+				if want, got := tc.expected, p; !reflect.DeepEqual(want, got) {
+					t.Errorf("unexpected result, want: %#v, got: %#v", want, got)
+				}
+			}
+		})
+	}
+}
