From d33e402cf2b6a5a7babf32849bd0aca33d0c9635 Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Sat, 14 Feb 2026 11:48:23 -0500 Subject: [PATCH 1/9] First gh mock server for github actions --- cmd/cmd_root.go | 5 + core/graph.go | 13 + github/server/cache.go | 266 +++++++++++++ github/server/cache_test.go | 269 +++++++++++++ github/server/legacy.go | 282 ++++++++++++++ github/server/legacy_test.go | 370 ++++++++++++++++++ github/server/oidc.go | 89 +++++ github/server/oidc_test.go | 173 +++++++++ github/server/server.go | 719 +++++++++++++++++++++++++++++++++++ github/server/server_test.go | 713 ++++++++++++++++++++++++++++++++++ github/server/start.go | 100 +++++ github/server/start_test.go | 115 ++++++ 12 files changed, 3114 insertions(+) create mode 100644 github/server/cache.go create mode 100644 github/server/cache_test.go create mode 100644 github/server/legacy.go create mode 100644 github/server/legacy_test.go create mode 100644 github/server/oidc.go create mode 100644 github/server/oidc_test.go create mode 100644 github/server/server.go create mode 100644 github/server/server_test.go create mode 100644 github/server/start.go create mode 100644 github/server/start_test.go diff --git a/cmd/cmd_root.go b/cmd/cmd_root.go index fbeb552..d0eacc4 100644 --- a/cmd/cmd_root.go +++ b/cmd/cmd_root.go @@ -31,6 +31,7 @@ var ( flagEnvFile string flagCreateDebugSession bool flagLocal bool + flagLocalGhServer bool finalConfigFile string finalConcurrency string @@ -38,6 +39,7 @@ var ( finalConfigValueSource string finalCreateDebugSession bool finalLocal bool + finalLocalGhServer bool finalGraphFile string finalGraphArgs []string @@ -112,6 +114,7 @@ var cmdRoot = &cobra.Command{ finalCreateDebugSession = finalCreateDebugSessionStr == "true" || finalCreateDebugSessionStr == "1" finalLocal = flagLocal + finalLocalGhServer = flagLocalGhServer // the block below is used to distinguish between implicit graph files (eg if defined in an env var) + graph flags // vs explicit graph file (eg provided by positional arg) + graph flags. @@ -201,6 +204,7 @@ func cmdRootRun(cmd *cobra.Command, args []string) { OverrideSecrets: nil, OverrideInputs: nil, Args: finalGraphArgs, + LocalGhServer: finalLocalGhServer, } if core.IsSharedGraphURL(finalGraphFile) { @@ -253,6 +257,7 @@ func init() { cmdRoot.Flags().StringVar(&flagSessionToken, "session-token", "", "The session token from your browser") cmdRoot.Flags().BoolVar(&flagCreateDebugSession, "create-debug-session", false, "Create a debug session by connecting to the web app") cmdRoot.Flags().BoolVar(&flagLocal, "local", false, "Start a local WebSocket server for direct editor connection") + cmdRoot.Flags().BoolVar(&flagLocalGhServer, "local-gh-server", false, "Start a local server mimicking GitHub Actions artifact, cache, and OIDC services") // disable interspersed flag parsing to allow passing arbitrary flags to graphs. // it stops cobra from parsing flags once it hits positional argument diff --git a/core/graph.go b/core/graph.go index b5b0b32..43c2216 100644 --- a/core/graph.go +++ b/core/graph.go @@ -16,6 +16,7 @@ import ( "sync" "time" + "github.com/actionforge/actrun-cli/github/server" "github.com/actionforge/actrun-cli/utils" "github.com/google/uuid" @@ -30,6 +31,7 @@ type RunOpts struct { OverrideInputs map[string]any OverrideEnv map[string]string Args []string + LocalGhServer bool } type ActionGraph struct { @@ -471,6 +473,17 @@ func RunGraph(ctx context.Context, graphName string, graphContent []byte, opts R return CreateErr(nil, err, "failed to setup GitHub Actions environment") } + if opts.LocalGhServer { + storageDir := filepath.Join(finalEnv["RUNNER_TEMP"], "gh-server-storage") + rs, srvErr := server.StartServer(server.Config{StorageDir: storageDir}) + if srvErr != nil { + return CreateErr(nil, srvErr, "failed to start local GitHub Actions server") + } + defer rs.Stop() + rs.InjectEnv(finalEnv) + utils.LogOut.Infof("local GitHub Actions server started at %s\n", rs.URL) + } + // Use the updated GITHUB_WORKSPACE as the working directory. // SetupGitHubActionsEnv replaces GITHUB_WORKSPACE with a fresh temp folder. if cwd, ok := finalEnv["GITHUB_WORKSPACE"]; ok { diff --git a/github/server/cache.go b/github/server/cache.go new file mode 100644 index 0000000..c11e936 --- /dev/null +++ b/github/server/cache.go @@ -0,0 +1,266 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +type CacheEntry struct { + ID int64 + Key string + Version string + Scope string + Size int64 + Finalized bool + CreatedAt time.Time +} + +// --- Twirp dispatcher --- + +func (s *Server) handleCacheTwirp(w http.ResponseWriter, r *http.Request) { + if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "Content-Type must be application/json") + return + } + + if _, _, err := parseJWT(r.Header.Get("Authorization")); err != nil { + writeTwirpError(w, http.StatusUnauthorized, "unauthenticated", err.Error()) + return + } + + method := r.PathValue("method") + switch method { + case "CreateCacheEntry": + s.handleCreateCacheEntry(w, r) + case "FinalizeCacheEntryUpload": + s.handleFinalizeCacheEntry(w, r) + case "GetCacheEntryDownloadURL": + s.handleGetCacheEntryDownloadURL(w, r) + case "DeleteCacheEntry": + s.handleDeleteCacheEntry(w, r) + default: + writeTwirpError(w, http.StatusNotFound, "not_found", fmt.Sprintf("unknown method: %s", method)) + } +} + +// --- Request/Response types --- + +type CacheMetadata struct { + RepositoryID string `json:"repository_id"` + Scope string `json:"scope"` +} + +type CreateCacheEntryRequest struct { + Key string `json:"key"` + Version string `json:"version"` + Metadata *CacheMetadata `json:"metadata,omitempty"` +} + +type CreateCacheEntryResponse struct { + Ok bool `json:"ok"` + SignedUploadURL string `json:"signed_upload_url"` +} + +type FinalizeCacheEntryRequest struct { + Key string `json:"key"` + Version string `json:"version"` + SizeBytes int64 `json:"size_bytes"` +} + +type FinalizeCacheEntryResponse struct { + Ok bool `json:"ok"` + EntryID string `json:"entry_id"` +} + +type GetCacheEntryDownloadURLRequest struct { + Metadata *CacheMetadata `json:"metadata,omitempty"` + Key string `json:"key"` + RestoreKeys []string `json:"restore_keys,omitempty"` + Version string `json:"version"` +} + +type GetCacheEntryDownloadURLResponse struct { + Ok bool `json:"ok"` + SignedDownloadURL string `json:"signed_download_url"` +} + +type DeleteCacheEntryRequest struct { + Key string `json:"key"` + Version string `json:"version"` +} + +type DeleteCacheEntryResponse struct { + Ok bool `json:"ok"` +} + +// --- RPC handlers --- + +func (s *Server) handleCreateCacheEntry(w http.ResponseWriter, r *http.Request) { + var req CreateCacheEntryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid JSON") + return + } + if req.Key == "" || req.Version == "" { + writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "key and version are required") + return + } + + scope := "" + if req.Metadata != nil { + scope = req.Metadata.Scope + } + cacheKey := scope + "/" + req.Key + "/" + req.Version + + s.mu.Lock() + // If entry already exists, overwrite (caches are mutable) + if existing, ok := s.caches[cacheKey]; ok { + delete(s.cacheByID, existing.ID) + delete(s.uploadMu, existing.ID) + } + id := s.nextID + s.nextID++ + entry := &CacheEntry{ + ID: id, + Key: req.Key, + Version: req.Version, + Scope: scope, + CreatedAt: time.Now(), + } + s.caches[cacheKey] = entry + s.cacheByID[id] = entry + s.uploadMu[id] = &sync.Mutex{} + s.mu.Unlock() + + uploadURL := s.makeSignedURL("PUT", id) + writeJSON(w, http.StatusOK, CreateCacheEntryResponse{ + Ok: true, + SignedUploadURL: uploadURL, + }) +} + +func (s *Server) handleFinalizeCacheEntry(w http.ResponseWriter, r *http.Request) { + var req FinalizeCacheEntryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid JSON") + return + } + + s.mu.Lock() + var found *CacheEntry + for _, entry := range s.caches { + if entry.Key == req.Key && entry.Version == req.Version { + found = entry + break + } + } + if found == nil { + s.mu.Unlock() + writeTwirpError(w, http.StatusNotFound, "not_found", "cache entry not found") + return + } + found.Size = req.SizeBytes + found.Finalized = true + s.mu.Unlock() + + writeJSON(w, http.StatusOK, FinalizeCacheEntryResponse{ + Ok: true, + EntryID: strconv.FormatInt(found.ID, 10), + }) +} + +func (s *Server) handleGetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Request) { + var req GetCacheEntryDownloadURLRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid JSON") + return + } + + scope := "" + if req.Metadata != nil { + scope = req.Metadata.Scope + } + + s.mu.RLock() + defer s.mu.RUnlock() + + // 1. Exact match: scope + key + version + exactKey := scope + "/" + req.Key + "/" + req.Version + if entry, ok := s.caches[exactKey]; ok && entry.Finalized { + downloadURL := s.makeSignedURL("GET", entry.ID) + writeJSON(w, http.StatusOK, GetCacheEntryDownloadURLResponse{ + Ok: true, + SignedDownloadURL: downloadURL, + }) + return + } + + // 2. Prefix match with restore_keys + for _, rk := range req.RestoreKeys { + var best *CacheEntry + for _, entry := range s.caches { + if entry.Scope != scope || entry.Version != req.Version { + continue + } + if !entry.Finalized { + continue + } + if !strings.HasPrefix(entry.Key, rk) { + continue + } + if best == nil || entry.CreatedAt.After(best.CreatedAt) { + best = entry + } + } + if best != nil { + downloadURL := s.makeSignedURL("GET", best.ID) + writeJSON(w, http.StatusOK, GetCacheEntryDownloadURLResponse{ + Ok: true, + SignedDownloadURL: downloadURL, + }) + return + } + } + + writeTwirpError(w, http.StatusNotFound, "not_found", "cache entry not found") +} + +func (s *Server) handleDeleteCacheEntry(w http.ResponseWriter, r *http.Request) { + var req DeleteCacheEntryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid JSON") + return + } + + s.mu.Lock() + var found *CacheEntry + var foundKey string + for k, entry := range s.caches { + if entry.Key == req.Key && entry.Version == req.Version { + found = entry + foundKey = k + break + } + } + if found == nil { + s.mu.Unlock() + writeTwirpError(w, http.StatusNotFound, "not_found", "cache entry not found") + return + } + delete(s.caches, foundKey) + delete(s.cacheByID, found.ID) + delete(s.uploadMu, found.ID) + s.mu.Unlock() + + blobPath := filepath.Join(s.storageDir, fmt.Sprintf("%d.blob", found.ID)) + os.Remove(blobPath) + + writeJSON(w, http.StatusOK, DeleteCacheEntryResponse{Ok: true}) +} diff --git a/github/server/cache_test.go b/github/server/cache_test.go new file mode 100644 index 0000000..0091489 --- /dev/null +++ b/github/server/cache_test.go @@ -0,0 +1,269 @@ +package server + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func cacheTwirpRequest(t *testing.T, ts *httptest.Server, method string, token string, body any) *http.Response { + t.Helper() + b, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + url := ts.URL + "/twirp/github.actions.results.api.v1.CacheService/" + method + req, err := http.NewRequest("POST", url, bytes.NewReader(b)) + if err != nil { + t.Fatalf("create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + return resp +} + +func TestCacheFullCycle(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + token := makeTestJWT("Actions.Results:run1:job1") + blobContent := []byte("cached data here") + + // 1. CreateCacheEntry + resp := cacheTwirpRequest(t, ts, "CreateCacheEntry", token, map[string]any{ + "key": "node-modules-abc123", + "version": "v1", + "metadata": map[string]string{ + "scope": "refs/heads/main", + }, + }) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("CreateCacheEntry status=%d body=%s", resp.StatusCode, body) + } + createResp := decodeResponse[CreateCacheEntryResponse](t, resp) + if !createResp.Ok || createResp.SignedUploadURL == "" { + t.Fatalf("CreateCacheEntry failed: %+v", createResp) + } + + // 2. Upload blob + req, _ := http.NewRequest("PUT", createResp.SignedUploadURL, bytes.NewReader(blobContent)) + uploadResp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("upload blob: %v", err) + } + uploadResp.Body.Close() + if uploadResp.StatusCode != http.StatusCreated { + t.Fatalf("upload status=%d", uploadResp.StatusCode) + } + + // 3. FinalizeCacheEntryUpload + resp = cacheTwirpRequest(t, ts, "FinalizeCacheEntryUpload", token, map[string]any{ + "key": "node-modules-abc123", + "version": "v1", + "size_bytes": len(blobContent), + }) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("FinalizeCacheEntryUpload status=%d body=%s", resp.StatusCode, body) + } + finalResp := decodeResponse[FinalizeCacheEntryResponse](t, resp) + if !finalResp.Ok { + t.Fatal("FinalizeCacheEntryUpload ok=false") + } + + // 4. GetCacheEntryDownloadURL + resp = cacheTwirpRequest(t, ts, "GetCacheEntryDownloadURL", token, map[string]any{ + "key": "node-modules-abc123", + "version": "v1", + "metadata": map[string]string{ + "scope": "refs/heads/main", + }, + }) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("GetCacheEntryDownloadURL status=%d body=%s", resp.StatusCode, body) + } + dlURLResp := decodeResponse[GetCacheEntryDownloadURLResponse](t, resp) + if !dlURLResp.Ok || dlURLResp.SignedDownloadURL == "" { + t.Fatalf("GetCacheEntryDownloadURL failed: %+v", dlURLResp) + } + + // 5. Download blob + getResp, err := http.Get(dlURLResp.SignedDownloadURL) + if err != nil { + t.Fatalf("download blob: %v", err) + } + defer getResp.Body.Close() + data, _ := io.ReadAll(getResp.Body) + if !bytes.Equal(data, blobContent) { + t.Fatalf("cache data mismatch: got %q, want %q", data, blobContent) + } +} + +func TestCachePrefixMatch(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + token := makeTestJWT("Actions.Results:run1:job1") + scope := "refs/heads/main" + + // Create and finalize two entries with different keys but same prefix + for _, key := range []string{"node-modules-aaa", "node-modules-bbb"} { + resp := cacheTwirpRequest(t, ts, "CreateCacheEntry", token, map[string]any{ + "key": key, + "version": "v1", + "metadata": map[string]string{"scope": scope}, + }) + createResp := decodeResponse[CreateCacheEntryResponse](t, resp) + // Upload something + req, _ := http.NewRequest("PUT", createResp.SignedUploadURL, bytes.NewReader([]byte(key))) + r, _ := http.DefaultClient.Do(req) + r.Body.Close() + // Finalize + resp = cacheTwirpRequest(t, ts, "FinalizeCacheEntryUpload", token, map[string]any{ + "key": key, + "version": "v1", + "size_bytes": len(key), + }) + decodeResponse[FinalizeCacheEntryResponse](t, resp) + } + + // Lookup with restore_keys prefix + resp := cacheTwirpRequest(t, ts, "GetCacheEntryDownloadURL", token, map[string]any{ + "key": "node-modules-ccc", + "version": "v1", + "restore_keys": []string{"node-modules-"}, + "metadata": map[string]string{"scope": scope}, + }) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("prefix lookup status=%d body=%s", resp.StatusCode, body) + } + dlResp := decodeResponse[GetCacheEntryDownloadURLResponse](t, resp) + if !dlResp.Ok || dlResp.SignedDownloadURL == "" { + t.Fatal("prefix match should return a download URL") + } + + // Download and verify it's one of the two entries (the newest) + getResp, _ := http.Get(dlResp.SignedDownloadURL) + defer getResp.Body.Close() + data, _ := io.ReadAll(getResp.Body) + if string(data) != "node-modules-bbb" { + t.Fatalf("prefix match returned %q, expected newest entry", data) + } +} + +func TestCacheOverwrite(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + token := makeTestJWT("Actions.Results:run1:job1") + scope := "refs/heads/main" + + // Create, upload, finalize first version + resp := cacheTwirpRequest(t, ts, "CreateCacheEntry", token, map[string]any{ + "key": "my-cache", + "version": "v1", + "metadata": map[string]string{"scope": scope}, + }) + createResp := decodeResponse[CreateCacheEntryResponse](t, resp) + req, _ := http.NewRequest("PUT", createResp.SignedUploadURL, bytes.NewReader([]byte("old-data"))) + r, _ := http.DefaultClient.Do(req) + r.Body.Close() + cacheTwirpRequest(t, ts, "FinalizeCacheEntryUpload", token, map[string]any{ + "key": "my-cache", "version": "v1", "size_bytes": 8, + }) + + // Overwrite with new data + resp = cacheTwirpRequest(t, ts, "CreateCacheEntry", token, map[string]any{ + "key": "my-cache", + "version": "v1", + "metadata": map[string]string{"scope": scope}, + }) + createResp = decodeResponse[CreateCacheEntryResponse](t, resp) + req, _ = http.NewRequest("PUT", createResp.SignedUploadURL, bytes.NewReader([]byte("new-data"))) + r, _ = http.DefaultClient.Do(req) + r.Body.Close() + cacheTwirpRequest(t, ts, "FinalizeCacheEntryUpload", token, map[string]any{ + "key": "my-cache", "version": "v1", "size_bytes": 8, + }) + + // Download should return new data + resp = cacheTwirpRequest(t, ts, "GetCacheEntryDownloadURL", token, map[string]any{ + "key": "my-cache", + "version": "v1", + "metadata": map[string]string{"scope": scope}, + }) + dlResp := decodeResponse[GetCacheEntryDownloadURLResponse](t, resp) + getResp, _ := http.Get(dlResp.SignedDownloadURL) + defer getResp.Body.Close() + data, _ := io.ReadAll(getResp.Body) + if string(data) != "new-data" { + t.Fatalf("overwrite failed: got %q", data) + } +} + +func TestCacheMiss(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + token := makeTestJWT("Actions.Results:run1:job1") + + resp := cacheTwirpRequest(t, ts, "GetCacheEntryDownloadURL", token, map[string]any{ + "key": "nonexistent", + "version": "v1", + "metadata": map[string]string{"scope": "refs/heads/main"}, + }) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 for cache miss, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestCacheInvalidAuth(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + b, _ := json.Marshal(map[string]any{"key": "k", "version": "v"}) + req, _ := http.NewRequest("POST", + ts.URL+"/twirp/github.actions.results.api.v1.CacheService/CreateCacheEntry", + bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + // No auth header + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } +} + +func TestCacheInvalidContentType(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + req, _ := http.NewRequest("POST", + ts.URL+"/twirp/github.actions.results.api.v1.CacheService/CreateCacheEntry", + strings.NewReader("{}")) + req.Header.Set("Content-Type", "text/plain") + req.Header.Set("Authorization", "Bearer "+makeTestJWT("Actions.Results:run1:job1")) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} diff --git a/github/server/legacy.go b/github/server/legacy.go new file mode 100644 index 0000000..29cc37a --- /dev/null +++ b/github/server/legacy.go @@ -0,0 +1,282 @@ +package server + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" +) + +type ArtifactContainer struct { + ID int64 + RunID string + Name string + Files map[string]*ContainerFile + Finalized bool +} + +type ContainerFile struct { + Path string + Size int64 + ContentGzip bool +} + +// POST /_apis/pipelines/workflows/{runId}/artifacts +func (s *Server) handleLegacyCreate(w http.ResponseWriter, r *http.Request) { + if !hasBearer(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + runID := r.PathValue("runId") + var req struct { + Name string `json:"name"` + Type string `json:"type"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + if req.Name == "" { + http.Error(w, "name is required", http.StatusBadRequest) + return + } + + key := runID + "/" + req.Name + + s.mu.Lock() + id := s.nextID + s.nextID++ + container := &ArtifactContainer{ + ID: id, + RunID: runID, + Name: req.Name, + Files: make(map[string]*ContainerFile), + } + s.containers[key] = container + s.contByID[id] = container + s.mu.Unlock() + + writeJSON(w, http.StatusOK, map[string]any{ + "fileContainerResourceUrl": fmt.Sprintf("%s/v3-upload/%d", s.externalURL, id), + }) +} + +// PUT /v3-upload/{containerId}?itemPath={path} +func (s *Server) handleLegacyUpload(w http.ResponseWriter, r *http.Request) { + cidStr := r.PathValue("containerId") + cid, err := strconv.ParseInt(cidStr, 10, 64) + if err != nil { + http.Error(w, "invalid container ID", http.StatusBadRequest) + return + } + + s.mu.RLock() + container, ok := s.contByID[cid] + s.mu.RUnlock() + if !ok { + http.Error(w, "container not found", http.StatusNotFound) + return + } + + itemPath := r.URL.Query().Get("itemPath") + if itemPath == "" { + http.Error(w, "itemPath is required", http.StatusBadRequest) + return + } + + dir := filepath.Join(s.storageDir, "v3", cidStr, filepath.Dir(itemPath)) + if err := os.MkdirAll(dir, 0o755); err != nil { + http.Error(w, "storage error", http.StatusInternalServerError) + return + } + + filePath := filepath.Join(s.storageDir, "v3", cidStr, itemPath) + + // Parse Content-Range for chunked uploads: "bytes {start}-{end}/{total}" + var start int64 + if cr := r.Header.Get("Content-Range"); cr != "" { + fmt.Sscanf(cr, "bytes %d-", &start) + } + + f, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + http.Error(w, "storage error", http.StatusInternalServerError) + return + } + if start > 0 { + f.Seek(start, io.SeekStart) + } + n, copyErr := io.Copy(f, r.Body) + f.Close() + if copyErr != nil { + http.Error(w, "storage error", http.StatusInternalServerError) + return + } + + isGzip := r.Header.Get("Content-Encoding") == "gzip" + + s.mu.Lock() + cf := container.Files[itemPath] + if cf == nil { + cf = &ContainerFile{Path: itemPath} + container.Files[itemPath] = cf + } + cf.Size = start + n + cf.ContentGzip = isGzip + s.mu.Unlock() + + writeJSON(w, http.StatusCreated, map[string]string{"message": "success"}) +} + +// PATCH /_apis/pipelines/workflows/{runId}/artifacts?artifactName={name} +func (s *Server) handleLegacyFinalize(w http.ResponseWriter, r *http.Request) { + if !hasBearer(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + runID := r.PathValue("runId") + name := r.URL.Query().Get("artifactName") + + s.mu.Lock() + for _, container := range s.containers { + if container.RunID != runID { + continue + } + if name != "" && container.Name != name { + continue + } + container.Finalized = true + } + s.mu.Unlock() + + writeJSON(w, http.StatusOK, map[string]string{"message": "success"}) +} + +// GET /_apis/pipelines/workflows/{runId}/artifacts +func (s *Server) handleLegacyList(w http.ResponseWriter, r *http.Request) { + if !hasBearer(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + runID := r.PathValue("runId") + + s.mu.RLock() + var items []map[string]any + for _, container := range s.containers { + if container.RunID != runID || !container.Finalized { + continue + } + items = append(items, map[string]any{ + "name": container.Name, + "fileContainerResourceUrl": fmt.Sprintf("%s/download-v3/%d", s.externalURL, container.ID), + }) + } + s.mu.RUnlock() + + if items == nil { + items = []map[string]any{} + } + + writeJSON(w, http.StatusOK, map[string]any{ + "count": len(items), + "value": items, + }) +} + +// GET /download-v3/{containerId}?itemPath={prefix} +func (s *Server) handleLegacyListFiles(w http.ResponseWriter, r *http.Request) { + cidStr := r.PathValue("containerId") + cid, err := strconv.ParseInt(cidStr, 10, 64) + if err != nil { + http.Error(w, "invalid container ID", http.StatusBadRequest) + return + } + + s.mu.RLock() + container, ok := s.contByID[cid] + if !ok { + s.mu.RUnlock() + http.Error(w, "container not found", http.StatusNotFound) + return + } + + prefix := r.URL.Query().Get("itemPath") + var items []map[string]any + for _, file := range container.Files { + if prefix != "" && !strings.HasPrefix(file.Path, prefix) { + continue + } + items = append(items, map[string]any{ + "path": file.Path, + "itemType": "file", + "contentLocation": fmt.Sprintf("%s/artifact/%d/%s", s.externalURL, cid, file.Path), + }) + } + s.mu.RUnlock() + + if items == nil { + items = []map[string]any{} + } + + writeJSON(w, http.StatusOK, map[string]any{ + "value": items, + }) +} + +// GET /artifact/{path...} +func (s *Server) handleLegacyDownload(w http.ResponseWriter, r *http.Request) { + fullPath := r.PathValue("path") + idx := strings.IndexByte(fullPath, '/') + if idx < 0 { + http.Error(w, "invalid path", http.StatusBadRequest) + return + } + cidStr := fullPath[:idx] + filePath := fullPath[idx+1:] + + cid, err := strconv.ParseInt(cidStr, 10, 64) + if err != nil { + http.Error(w, "invalid container ID", http.StatusBadRequest) + return + } + + s.mu.RLock() + container, ok := s.contByID[cid] + if !ok { + s.mu.RUnlock() + http.Error(w, "container not found", http.StatusNotFound) + return + } + cf, ok := container.Files[filePath] + s.mu.RUnlock() + if !ok { + http.Error(w, "file not found", http.StatusNotFound) + return + } + + diskPath := filepath.Join(s.storageDir, "v3", cidStr, filePath) + f, err := os.Open(diskPath) + if err != nil { + http.Error(w, "file not found", http.StatusNotFound) + return + } + defer f.Close() + + if cf.ContentGzip { + w.Header().Set("Content-Encoding", "gzip") + } + w.Header().Set("Content-Type", "application/octet-stream") + io.Copy(w, f) +} + +func hasBearer(r *http.Request) bool { + auth := r.Header.Get("Authorization") + return strings.HasPrefix(auth, "Bearer ") && len(auth) > 7 +} diff --git a/github/server/legacy_test.go b/github/server/legacy_test.go new file mode 100644 index 0000000..1b7a7eb --- /dev/null +++ b/github/server/legacy_test.go @@ -0,0 +1,370 @@ +package server + +import ( + "bytes" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func legacyRequest(t *testing.T, ts *httptest.Server, method, path string, body any) *http.Response { + t.Helper() + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + bodyReader = bytes.NewReader(b) + } + req, err := http.NewRequest(method, ts.URL+path, bodyReader) + if err != nil { + t.Fatalf("create request: %v", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Authorization", "Bearer test-token") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + return resp +} + +func TestLegacyFullCycle(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + fileContent := []byte("hello legacy artifact") + + // 1. Create container + resp := legacyRequest(t, ts, "POST", "/_apis/pipelines/workflows/run1/artifacts", + map[string]string{"name": "my-artifact", "type": "actions_storage"}) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("create status=%d body=%s", resp.StatusCode, body) + } + var createResult map[string]string + json.NewDecoder(resp.Body).Decode(&createResult) + resp.Body.Close() + uploadURL := createResult["fileContainerResourceUrl"] + if uploadURL == "" { + t.Fatal("missing fileContainerResourceUrl") + } + + // 2. Upload file + req, _ := http.NewRequest("PUT", uploadURL+"?itemPath=data/file.txt", bytes.NewReader(fileContent)) + req.Header.Set("Content-Type", "application/octet-stream") + uploadResp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("upload: %v", err) + } + uploadResp.Body.Close() + if uploadResp.StatusCode != http.StatusCreated { + t.Fatalf("upload status=%d", uploadResp.StatusCode) + } + + // 3. Finalize + resp = legacyRequest(t, ts, "PATCH", "/_apis/pipelines/workflows/run1/artifacts?artifactName=my-artifact", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("finalize status=%d", resp.StatusCode) + } + resp.Body.Close() + + // 4. List containers + resp = legacyRequest(t, ts, "GET", "/_apis/pipelines/workflows/run1/artifacts", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("list status=%d", resp.StatusCode) + } + var listResult struct { + Count int `json:"count"` + Value []map[string]any `json:"value"` + } + json.NewDecoder(resp.Body).Decode(&listResult) + resp.Body.Close() + if listResult.Count != 1 { + t.Fatalf("expected 1 container, got %d", listResult.Count) + } + if listResult.Value[0]["name"] != "my-artifact" { + t.Fatalf("name=%v", listResult.Value[0]["name"]) + } + downloadListURL := listResult.Value[0]["fileContainerResourceUrl"].(string) + + // 5. List files in container + resp = legacyRequest(t, ts, "GET", extractPath(downloadListURL), nil) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("list files status=%d body=%s", resp.StatusCode, body) + } + var filesResult struct { + Value []map[string]any `json:"value"` + } + json.NewDecoder(resp.Body).Decode(&filesResult) + resp.Body.Close() + if len(filesResult.Value) != 1 { + t.Fatalf("expected 1 file, got %d", len(filesResult.Value)) + } + if filesResult.Value[0]["path"] != "data/file.txt" { + t.Fatalf("path=%v", filesResult.Value[0]["path"]) + } + contentLocation := filesResult.Value[0]["contentLocation"].(string) + + // 6. Download file + dlResp, err := http.Get(contentLocation) + if err != nil { + t.Fatalf("download: %v", err) + } + defer dlResp.Body.Close() + if dlResp.StatusCode != http.StatusOK { + t.Fatalf("download status=%d", dlResp.StatusCode) + } + data, _ := io.ReadAll(dlResp.Body) + if !bytes.Equal(data, fileContent) { + t.Fatalf("content mismatch: got %q", data) + } +} + +func TestLegacyChunkedUpload(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + // Create container + resp := legacyRequest(t, ts, "POST", "/_apis/pipelines/workflows/run2/artifacts", + map[string]string{"name": "chunked", "type": "actions_storage"}) + var createResult map[string]string + json.NewDecoder(resp.Body).Decode(&createResult) + resp.Body.Close() + uploadURL := createResult["fileContainerResourceUrl"] + + chunk1 := []byte("AAAA") + chunk2 := []byte("BBBB") + total := len(chunk1) + len(chunk2) + + // Upload chunk 1 + req, _ := http.NewRequest("PUT", uploadURL+"?itemPath=file.bin", bytes.NewReader(chunk1)) + req.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/%d", len(chunk1)-1, total)) + r, _ := http.DefaultClient.Do(req) + r.Body.Close() + if r.StatusCode != http.StatusCreated { + t.Fatalf("chunk1 status=%d", r.StatusCode) + } + + // Upload chunk 2 + req, _ = http.NewRequest("PUT", uploadURL+"?itemPath=file.bin", bytes.NewReader(chunk2)) + req.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", len(chunk1), total-1, total)) + r, _ = http.DefaultClient.Do(req) + r.Body.Close() + if r.StatusCode != http.StatusCreated { + t.Fatalf("chunk2 status=%d", r.StatusCode) + } + + // Finalize + resp = legacyRequest(t, ts, "PATCH", "/_apis/pipelines/workflows/run2/artifacts?artifactName=chunked", nil) + resp.Body.Close() + + // List files and download + resp = legacyRequest(t, ts, "GET", "/_apis/pipelines/workflows/run2/artifacts", nil) + var listResult struct { + Value []map[string]any `json:"value"` + } + json.NewDecoder(resp.Body).Decode(&listResult) + resp.Body.Close() + downloadListURL := listResult.Value[0]["fileContainerResourceUrl"].(string) + + resp = legacyRequest(t, ts, "GET", extractPath(downloadListURL), nil) + var filesResult struct { + Value []map[string]any `json:"value"` + } + json.NewDecoder(resp.Body).Decode(&filesResult) + resp.Body.Close() + contentLocation := filesResult.Value[0]["contentLocation"].(string) + + dlResp, _ := http.Get(contentLocation) + defer dlResp.Body.Close() + data, _ := io.ReadAll(dlResp.Body) + expected := append(chunk1, chunk2...) + if !bytes.Equal(data, expected) { + t.Fatalf("chunked content mismatch: got %q, want %q", data, expected) + } +} + +func TestLegacyGzipRoundtrip(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + // Create container + resp := legacyRequest(t, ts, "POST", "/_apis/pipelines/workflows/run3/artifacts", + map[string]string{"name": "gzipped", "type": "actions_storage"}) + var createResult map[string]string + json.NewDecoder(resp.Body).Decode(&createResult) + resp.Body.Close() + uploadURL := createResult["fileContainerResourceUrl"] + + // Gzip some data + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + gw.Write([]byte("compressed content")) + gw.Close() + gzipData := buf.Bytes() + + // Upload with Content-Encoding: gzip + req, _ := http.NewRequest("PUT", uploadURL+"?itemPath=data.gz", bytes.NewReader(gzipData)) + req.Header.Set("Content-Encoding", "gzip") + r, _ := http.DefaultClient.Do(req) + r.Body.Close() + if r.StatusCode != http.StatusCreated { + t.Fatalf("upload status=%d", r.StatusCode) + } + + // Finalize + resp = legacyRequest(t, ts, "PATCH", "/_apis/pipelines/workflows/run3/artifacts?artifactName=gzipped", nil) + resp.Body.Close() + + // List and download + resp = legacyRequest(t, ts, "GET", "/_apis/pipelines/workflows/run3/artifacts", nil) + var listResult struct{ Value []map[string]any } + json.NewDecoder(resp.Body).Decode(&listResult) + resp.Body.Close() + + resp = legacyRequest(t, ts, "GET", extractPath(listResult.Value[0]["fileContainerResourceUrl"].(string)), nil) + var filesResult struct{ Value []map[string]any } + json.NewDecoder(resp.Body).Decode(&filesResult) + resp.Body.Close() + + // Use raw HTTP transport to avoid automatic decompression + transport := &http.Transport{DisableCompression: true} + client := &http.Client{Transport: transport} + dlResp, _ := client.Get(filesResult.Value[0]["contentLocation"].(string)) + defer dlResp.Body.Close() + + if dlResp.Header.Get("Content-Encoding") != "gzip" { + t.Fatal("expected Content-Encoding: gzip on download") + } + + rawData, _ := io.ReadAll(dlResp.Body) + if !bytes.Equal(rawData, gzipData) { + t.Fatalf("gzip data mismatch: got %d bytes, want %d bytes", len(rawData), len(gzipData)) + } +} + +func TestLegacyMultipleFiles(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + // Create container + resp := legacyRequest(t, ts, "POST", "/_apis/pipelines/workflows/run4/artifacts", + map[string]string{"name": "multi", "type": "actions_storage"}) + var createResult map[string]string + json.NewDecoder(resp.Body).Decode(&createResult) + resp.Body.Close() + uploadURL := createResult["fileContainerResourceUrl"] + + files := map[string]string{ + "dir/a.txt": "content-a", + "dir/b.txt": "content-b", + "c.txt": "content-c", + } + + for path, content := range files { + req, _ := http.NewRequest("PUT", uploadURL+"?itemPath="+path, bytes.NewReader([]byte(content))) + r, _ := http.DefaultClient.Do(req) + r.Body.Close() + } + + // Finalize + resp = legacyRequest(t, ts, "PATCH", "/_apis/pipelines/workflows/run4/artifacts?artifactName=multi", nil) + resp.Body.Close() + + // List files + resp = legacyRequest(t, ts, "GET", "/_apis/pipelines/workflows/run4/artifacts", nil) + var listResult struct{ Value []map[string]any } + json.NewDecoder(resp.Body).Decode(&listResult) + resp.Body.Close() + + resp = legacyRequest(t, ts, "GET", extractPath(listResult.Value[0]["fileContainerResourceUrl"].(string)), nil) + var filesResult struct{ Value []map[string]any } + json.NewDecoder(resp.Body).Decode(&filesResult) + resp.Body.Close() + if len(filesResult.Value) != 3 { + t.Fatalf("expected 3 files, got %d", len(filesResult.Value)) + } + + // Filter by prefix + resp = legacyRequest(t, ts, "GET", extractPath(listResult.Value[0]["fileContainerResourceUrl"].(string))+"?itemPath=dir/", nil) + json.NewDecoder(resp.Body).Decode(&filesResult) + resp.Body.Close() + if len(filesResult.Value) != 2 { + t.Fatalf("expected 2 files with prefix dir/, got %d", len(filesResult.Value)) + } +} + +func TestLegacyNotFound(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + // List containers for non-existent run + resp := legacyRequest(t, ts, "GET", "/_apis/pipelines/workflows/norun/artifacts", nil) + var listResult struct { + Count int `json:"count"` + } + json.NewDecoder(resp.Body).Decode(&listResult) + resp.Body.Close() + if listResult.Count != 0 { + t.Fatalf("expected 0 containers, got %d", listResult.Count) + } + + // Download non-existent container + dlResp, _ := http.Get(ts.URL + "/download-v3/9999") + dlResp.Body.Close() + if dlResp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", dlResp.StatusCode) + } + + // Download non-existent file + dlResp, _ = http.Get(ts.URL + "/artifact/9999/nofile.txt") + dlResp.Body.Close() + if dlResp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", dlResp.StatusCode) + } +} + +func TestLegacyUnauthorized(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + req, _ := http.NewRequest("POST", ts.URL+"/_apis/pipelines/workflows/run1/artifacts", + bytes.NewReader([]byte(`{"name":"test"}`))) + req.Header.Set("Content-Type", "application/json") + // No auth header + resp, _ := http.DefaultClient.Do(req) + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } +} + +// extractPath extracts the path from a full URL for use with legacyRequest. +func extractPath(fullURL string) string { + // Find path after the host + if idx := len("http://"); idx < len(fullURL) { + rest := fullURL[idx:] + if slashIdx := slashIndex(rest); slashIdx >= 0 { + return rest[slashIdx:] + } + } + return fullURL +} + +func slashIndex(s string) int { + for i, c := range s { + if c == '/' { + return i + } + } + return -1 +} diff --git a/github/server/oidc.go b/github/server/oidc.go new file mode 100644 index 0000000..ce436a0 --- /dev/null +++ b/github/server/oidc.go @@ -0,0 +1,89 @@ +package server + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "strings" + "time" +) + +type OIDCConfig struct { + Issuer string + Subject string +} + +func (s *Server) handleOIDCToken(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"unauthorized"}`)) + return + } + + audience := r.URL.Query().Get("audience") + if audience == "" { + audience = s.externalURL + } + + now := time.Now() + sub := s.oidcCfg.Subject + ref := "refs/heads/main" + repository := "local/repo" + actor := "local-runner" + + // Parse subject: "repo:org/repo:ref:refs/heads/branch" + if strings.HasPrefix(sub, "repo:") { + rest := strings.TrimPrefix(sub, "repo:") + if idx := strings.Index(rest, ":ref:"); idx >= 0 { + repository = rest[:idx] + ref = rest[idx+5:] + } else if idx := strings.Index(rest, ":"); idx >= 0 { + repository = rest[:idx] + } + } + + claims := map[string]any{ + "iss": s.oidcCfg.Issuer, + "sub": sub, + "aud": audience, + "iat": now.Unix(), + "exp": now.Add(1 * time.Hour).Unix(), + "ref": ref, + "sha": "local", + "repository": repository, + "actor": actor, + "run_id": "1", + "run_number": "1", + "workflow": "local", + "event_name": "push", + } + + jwt, err := mintJWT(s.signingKey, claims) + if err != nil { + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"value": jwt}) +} + +func mintJWT(key []byte, claims map[string]any) (string, error) { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) + + claimsJSON, err := json.Marshal(claims) + if err != nil { + return "", err + } + payload := base64.RawURLEncoding.EncodeToString(claimsJSON) + + sigInput := header + "." + payload + mac := hmac.New(sha256.New, key) + mac.Write([]byte(sigInput)) + sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + + return sigInput + "." + sig, nil +} diff --git a/github/server/oidc_test.go b/github/server/oidc_test.go new file mode 100644 index 0000000..3e36825 --- /dev/null +++ b/github/server/oidc_test.go @@ -0,0 +1,173 @@ +package server + +import ( + "encoding/base64" + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +func TestOIDCTokenCustomAudience(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + req, _ := http.NewRequest("GET", ts.URL+"/_services/token?audience=sts.amazonaws.com", nil) + req.Header.Set("Authorization", "Bearer test-token") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status=%d body=%s", resp.StatusCode, body) + } + + var result map[string]string + json.NewDecoder(resp.Body).Decode(&result) + jwt := result["value"] + if jwt == "" { + t.Fatal("missing JWT value") + } + + claims := decodeJWTClaims(t, jwt) + if claims["aud"] != "sts.amazonaws.com" { + t.Fatalf("aud=%v, want sts.amazonaws.com", claims["aud"]) + } + if claims["iss"] != "https://token.actions.githubusercontent.com" { + t.Fatalf("iss=%v", claims["iss"]) + } + if claims["sub"] != "repo:local/repo:ref:refs/heads/main" { + t.Fatalf("sub=%v", claims["sub"]) + } + if claims["repository"] != "local/repo" { + t.Fatalf("repository=%v", claims["repository"]) + } + if claims["ref"] != "refs/heads/main" { + t.Fatalf("ref=%v", claims["ref"]) + } + if claims["actor"] != "local-runner" { + t.Fatalf("actor=%v", claims["actor"]) + } + if claims["event_name"] != "push" { + t.Fatalf("event_name=%v", claims["event_name"]) + } +} + +func TestOIDCTokenDefaultAudience(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + req, _ := http.NewRequest("GET", ts.URL+"/_services/token", nil) + req.Header.Set("Authorization", "Bearer test-token") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status=%d", resp.StatusCode) + } + + var result map[string]string + json.NewDecoder(resp.Body).Decode(&result) + claims := decodeJWTClaims(t, result["value"]) + + // Default audience should be the server's external URL + if !strings.HasPrefix(claims["aud"].(string), "http://") { + t.Fatalf("default aud=%v, expected server URL", claims["aud"]) + } +} + +func TestOIDCTokenMissingAuth(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/_services/token") + if err != nil { + t.Fatalf("request: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } +} + +func TestOIDCTokenEmptyBearer(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + req, _ := http.NewRequest("GET", ts.URL+"/_services/token", nil) + req.Header.Set("Authorization", "Bearer ") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } +} + +func TestOIDCJWTStructure(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + req, _ := http.NewRequest("GET", ts.URL+"/_services/token?audience=test", nil) + req.Header.Set("Authorization", "Bearer test-token") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + defer resp.Body.Close() + + var result map[string]string + json.NewDecoder(resp.Body).Decode(&result) + jwt := result["value"] + + parts := strings.Split(jwt, ".") + if len(parts) != 3 { + t.Fatalf("JWT should have 3 parts, got %d", len(parts)) + } + + // Decode header + headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + t.Fatalf("decode header: %v", err) + } + var header map[string]string + json.Unmarshal(headerJSON, &header) + if header["alg"] != "HS256" { + t.Fatalf("alg=%s, want HS256", header["alg"]) + } + if header["typ"] != "JWT" { + t.Fatalf("typ=%s, want JWT", header["typ"]) + } + + // Verify all expected claims exist + claims := decodeJWTClaims(t, jwt) + for _, key := range []string{"iss", "sub", "aud", "iat", "exp", "ref", "sha", "repository", "actor", "run_id", "run_number", "workflow", "event_name"} { + if _, ok := claims[key]; !ok { + t.Errorf("missing claim: %s", key) + } + } +} + +func decodeJWTClaims(t *testing.T, jwt string) map[string]any { + t.Helper() + parts := strings.Split(jwt, ".") + if len(parts) != 3 { + t.Fatalf("invalid JWT: %d parts", len(parts)) + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + t.Fatalf("decode payload: %v", err) + } + var claims map[string]any + if err := json.Unmarshal(payload, &claims); err != nil { + t.Fatalf("unmarshal claims: %v", err) + } + return claims +} diff --git a/github/server/server.go b/github/server/server.go new file mode 100644 index 0000000..df9fa7f --- /dev/null +++ b/github/server/server.go @@ -0,0 +1,719 @@ +package server + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +// Artifact represents a stored artifact with its metadata. +type Artifact struct { + ID int64 + Name string + RunBackendID string + JobBackendID string + BlobPath string // on-disk path: {storageDir}/{runBackendID}/{name} + Size int64 + Hash string + Finalized bool + CreatedAt time.Time + ExpiresAt time.Time + WorkflowName string + RunID int64 + WorkflowRunID int64 + MonolithRunID int64 +} + +// Server implements the GitHub Actions artifact service protocol. +type Server struct { + mu sync.RWMutex + // Artifact v4 state + artifacts map[string]*Artifact // "{runBackendId}/{name}" + artByID map[int64]*Artifact + // Cache state + caches map[string]*CacheEntry // "{scope}/{key}/{version}" + cacheByID map[int64]*CacheEntry + // Legacy artifact v3 state + containers map[string]*ArtifactContainer // "{runId}/{name}" + contByID map[int64]*ArtifactContainer + // Shared + uploadMu map[int64]*sync.Mutex + nextID int64 + storageDir string + signingKey []byte + externalURL string + oidcCfg OIDCConfig + mux *http.ServeMux +} + +const signedURLTTL = 60 * time.Minute + +// artifactBlobPath returns the on-disk storage path for an artifact. +// Layout: {storageDir}/{runBackendID}/{artifactName} +func (s *Server) artifactBlobPath(runBackendID, name string) string { + // Sanitize to prevent path traversal + safeRun := filepath.Base(runBackendID) + safeName := filepath.Base(name) + return filepath.Join(s.storageDir, safeRun, safeName) +} + +// getBlobPath returns the on-disk path for an artifact by its numeric ID. +// Caller must hold at least s.mu.RLock. +func (s *Server) getBlobPath(id int64) string { + if art, ok := s.artByID[id]; ok && art.BlobPath != "" { + return art.BlobPath + } + // Fallback for legacy/cache entries that don't have BlobPath set + return filepath.Join(s.storageDir, fmt.Sprintf("%d.blob", id)) +} + +func NewServer(storageDir string, signingKey []byte, externalURL string, oidcCfg OIDCConfig) *Server { + s := &Server{ + artifacts: make(map[string]*Artifact), + artByID: make(map[int64]*Artifact), + caches: make(map[string]*CacheEntry), + cacheByID: make(map[int64]*CacheEntry), + containers: make(map[string]*ArtifactContainer), + contByID: make(map[int64]*ArtifactContainer), + uploadMu: make(map[int64]*sync.Mutex), + nextID: 1, + storageDir: storageDir, + signingKey: signingKey, + externalURL: strings.TrimRight(externalURL, "/"), + oidcCfg: oidcCfg, + mux: http.NewServeMux(), + } + // Artifact v4 (Twirp) + s.mux.HandleFunc("POST /twirp/github.actions.results.api.v1.ArtifactService/{method}", s.handleTwirp) + // Cache (Twirp) + s.mux.HandleFunc("POST /twirp/github.actions.results.api.v1.CacheService/{method}", s.handleCacheTwirp) + // Blob upload/download (shared by v4 and cache) + s.mux.HandleFunc("PUT /upload/{id}", s.handleBlobUpload) + s.mux.HandleFunc("GET /download/{id}", s.handleBlobDownload) + // OIDC token service + s.mux.HandleFunc("GET /_services/token", s.handleOIDCToken) + // Legacy v3 artifact API + s.mux.HandleFunc("POST /_apis/pipelines/workflows/{runId}/artifacts", s.handleLegacyCreate) + s.mux.HandleFunc("PATCH /_apis/pipelines/workflows/{runId}/artifacts", s.handleLegacyFinalize) + s.mux.HandleFunc("GET /_apis/pipelines/workflows/{runId}/artifacts", s.handleLegacyList) + s.mux.HandleFunc("PUT /v3-upload/{containerId}", s.handleLegacyUpload) + s.mux.HandleFunc("GET /download-v3/{containerId}", s.handleLegacyListFiles) + s.mux.HandleFunc("GET /artifact/{path...}", s.handleLegacyDownload) + return s +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.mux.ServeHTTP(w, r) +} + +// --- Twirp dispatcher --- + +func (s *Server) handleTwirp(w http.ResponseWriter, r *http.Request) { + if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "Content-Type must be application/json") + return + } + + runID, jobID, err := parseJWT(r.Header.Get("Authorization")) + if err != nil { + writeTwirpError(w, http.StatusUnauthorized, "unauthenticated", err.Error()) + return + } + + method := r.PathValue("method") + switch method { + case "CreateArtifact": + s.handleCreateArtifact(w, r, runID, jobID) + case "FinalizeArtifact": + s.handleFinalizeArtifact(w, r, runID, jobID) + case "ListArtifacts": + s.handleListArtifacts(w, r, runID) + case "GetSignedArtifactURL": + s.handleGetSignedArtifactURL(w, r, runID) + case "DeleteArtifact": + s.handleDeleteArtifact(w, r, runID) + case "MigrateArtifact": + s.handleMigrateArtifact(w, r, runID) + case "FinalizeMigratedArtifact": + s.handleFinalizeMigratedArtifact(w, r, runID) + default: + writeTwirpError(w, http.StatusNotFound, "not_found", fmt.Sprintf("unknown method: %s", method)) + } +} + +// --- Request/Response types --- + +type CreateArtifactRequest struct { + WorkflowRunBackendID string `json:"workflow_run_backend_id"` + WorkflowJobRunBackendID string `json:"workflow_job_run_backend_id"` + Name string `json:"name"` + Version int `json:"version"` +} + +type CreateArtifactResponse struct { + Ok bool `json:"ok"` + SignedUploadURL string `json:"signed_upload_url"` +} + +type FinalizeArtifactRequest struct { + WorkflowRunBackendID string `json:"workflow_run_backend_id"` + WorkflowJobRunBackendID string `json:"workflow_job_run_backend_id"` + Name string `json:"name"` + Size string `json:"size"` + Hash *string `json:"hash,omitempty"` +} + +type FinalizeArtifactResponse struct { + Ok bool `json:"ok"` + ArtifactID string `json:"artifact_id"` +} + +type ListArtifactsRequest struct { + WorkflowRunBackendID string `json:"workflow_run_backend_id"` + NameFilter *string `json:"name_filter,omitempty"` + IDFilter *string `json:"id_filter,omitempty"` +} + +type ListArtifactsResponse struct { + Artifacts []ArtifactEntry `json:"artifacts"` +} + +type ArtifactEntry struct { + WorkflowRunBackendID string `json:"workflow_run_backend_id"` + WorkflowJobRunBackendID string `json:"workflow_job_run_backend_id"` + DatabaseID string `json:"database_id"` + Name string `json:"name"` + Size string `json:"size"` + CreatedAt *string `json:"created_at,omitempty"` +} + +type GetSignedArtifactURLRequest struct { + WorkflowRunBackendID string `json:"workflow_run_backend_id"` + Name string `json:"name"` +} + +type GetSignedArtifactURLResponse struct { + SignedURL string `json:"signed_url"` +} + +type DeleteArtifactRequest struct { + WorkflowRunBackendID string `json:"workflow_run_backend_id"` + Name string `json:"name"` +} + +type DeleteArtifactResponse struct { + Ok bool `json:"ok"` + ArtifactID string `json:"artifact_id"` +} + +type MigrateArtifactRequest struct { + WorkflowRunBackendID string `json:"workflow_run_backend_id"` + Name string `json:"name"` + Version int `json:"version"` +} + +type MigrateArtifactResponse struct { + Ok bool `json:"ok"` + SignedUploadURL string `json:"signed_upload_url"` +} + +type FinalizeMigratedArtifactRequest struct { + WorkflowRunBackendID string `json:"workflow_run_backend_id"` + Name string `json:"name"` + Size string `json:"size"` +} + +type FinalizeMigratedArtifactResponse struct { + Ok bool `json:"ok"` + ArtifactID string `json:"artifact_id"` +} + +// --- Twirp RPC handlers --- + +func (s *Server) handleCreateArtifact(w http.ResponseWriter, r *http.Request, runID, jobID string) { + var req CreateArtifactRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid JSON") + return + } + + if req.Name == "" { + writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "name is required") + return + } + if req.WorkflowRunBackendID == "" { + req.WorkflowRunBackendID = runID + } + if req.WorkflowJobRunBackendID == "" { + req.WorkflowJobRunBackendID = jobID + } + + key := req.WorkflowRunBackendID + "/" + req.Name + + blobPath := s.artifactBlobPath(req.WorkflowRunBackendID, req.Name) + + s.mu.Lock() + if existing, ok := s.artifacts[key]; ok && existing.Finalized { + s.mu.Unlock() + writeTwirpError(w, http.StatusConflict, "already_exists", + fmt.Sprintf("an artifact with this name already exists on the workflow run: %s", req.Name)) + return + } + id := s.nextID + s.nextID++ + art := &Artifact{ + ID: id, + Name: req.Name, + RunBackendID: req.WorkflowRunBackendID, + JobBackendID: req.WorkflowJobRunBackendID, + BlobPath: blobPath, + CreatedAt: time.Now(), + } + s.artifacts[key] = art + s.artByID[id] = art + s.uploadMu[id] = &sync.Mutex{} + s.mu.Unlock() + + // Ensure run subdirectory exists + if err := os.MkdirAll(filepath.Dir(blobPath), 0o755); err != nil { + writeTwirpError(w, http.StatusInternalServerError, "internal", "failed to create storage directory") + return + } + + uploadURL := s.makeSignedURL("PUT", id) + + writeJSON(w, http.StatusOK, CreateArtifactResponse{ + Ok: true, + SignedUploadURL: uploadURL, + }) +} + +func (s *Server) handleFinalizeArtifact(w http.ResponseWriter, r *http.Request, runID, jobID string) { + var req FinalizeArtifactRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid JSON") + return + } + if req.WorkflowRunBackendID == "" { + req.WorkflowRunBackendID = runID + } + + key := req.WorkflowRunBackendID + "/" + req.Name + + s.mu.Lock() + art, ok := s.artifacts[key] + if !ok { + s.mu.Unlock() + writeTwirpError(w, http.StatusNotFound, "not_found", fmt.Sprintf("artifact %q not found", req.Name)) + return + } + + size, _ := strconv.ParseInt(req.Size, 10, 64) + art.Size = size + if req.Hash != nil { + art.Hash = *req.Hash + } + art.Finalized = true + s.mu.Unlock() + + writeJSON(w, http.StatusOK, FinalizeArtifactResponse{ + Ok: true, + ArtifactID: strconv.FormatInt(art.ID, 10), + }) +} + +func (s *Server) handleListArtifacts(w http.ResponseWriter, r *http.Request, runID string) { + var req ListArtifactsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid JSON") + return + } + if req.WorkflowRunBackendID == "" { + req.WorkflowRunBackendID = runID + } + + s.mu.RLock() + var entries []ArtifactEntry + for _, art := range s.artifacts { + if art.RunBackendID != req.WorkflowRunBackendID { + continue + } + if !art.Finalized { + continue + } + if req.NameFilter != nil && art.Name != *req.NameFilter { + continue + } + if req.IDFilter != nil { + filterID, _ := strconv.ParseInt(*req.IDFilter, 10, 64) + if art.ID != filterID { + continue + } + } + ts := art.CreatedAt.UTC().Format(time.RFC3339) + entries = append(entries, ArtifactEntry{ + WorkflowRunBackendID: art.RunBackendID, + WorkflowJobRunBackendID: art.JobBackendID, + DatabaseID: strconv.FormatInt(art.ID, 10), + Name: art.Name, + Size: strconv.FormatInt(art.Size, 10), + CreatedAt: &ts, + }) + } + s.mu.RUnlock() + + if entries == nil { + entries = []ArtifactEntry{} + } + + writeJSON(w, http.StatusOK, ListArtifactsResponse{Artifacts: entries}) +} + +func (s *Server) handleGetSignedArtifactURL(w http.ResponseWriter, r *http.Request, runID string) { + var req GetSignedArtifactURLRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid JSON") + return + } + if req.WorkflowRunBackendID == "" { + req.WorkflowRunBackendID = runID + } + + key := req.WorkflowRunBackendID + "/" + req.Name + + s.mu.RLock() + art, ok := s.artifacts[key] + s.mu.RUnlock() + + if !ok || !art.Finalized { + writeTwirpError(w, http.StatusNotFound, "not_found", fmt.Sprintf("artifact %q not found", req.Name)) + return + } + + downloadURL := s.makeSignedURL("GET", art.ID) + + writeJSON(w, http.StatusOK, GetSignedArtifactURLResponse{ + SignedURL: downloadURL, + }) +} + +func (s *Server) handleDeleteArtifact(w http.ResponseWriter, r *http.Request, runID string) { + var req DeleteArtifactRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid JSON") + return + } + if req.WorkflowRunBackendID == "" { + req.WorkflowRunBackendID = runID + } + + key := req.WorkflowRunBackendID + "/" + req.Name + + s.mu.Lock() + art, ok := s.artifacts[key] + if !ok { + s.mu.Unlock() + writeTwirpError(w, http.StatusNotFound, "not_found", fmt.Sprintf("artifact %q not found", req.Name)) + return + } + delete(s.artifacts, key) + delete(s.artByID, art.ID) + delete(s.uploadMu, art.ID) + s.mu.Unlock() + + os.Remove(art.BlobPath) + + writeJSON(w, http.StatusOK, DeleteArtifactResponse{ + Ok: true, + ArtifactID: strconv.FormatInt(art.ID, 10), + }) +} + +func (s *Server) handleMigrateArtifact(w http.ResponseWriter, r *http.Request, runID string) { + var req MigrateArtifactRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid JSON") + return + } + + if req.Name == "" { + writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "name is required") + return + } + if req.WorkflowRunBackendID == "" { + req.WorkflowRunBackendID = runID + } + + key := req.WorkflowRunBackendID + "/" + req.Name + blobPath := s.artifactBlobPath(req.WorkflowRunBackendID, req.Name) + + s.mu.Lock() + if existing, ok := s.artifacts[key]; ok && existing.Finalized { + s.mu.Unlock() + writeTwirpError(w, http.StatusConflict, "already_exists", + fmt.Sprintf("an artifact with this name already exists on the workflow run: %s", req.Name)) + return + } + id := s.nextID + s.nextID++ + art := &Artifact{ + ID: id, + Name: req.Name, + RunBackendID: req.WorkflowRunBackendID, + BlobPath: blobPath, + CreatedAt: time.Now(), + } + s.artifacts[key] = art + s.artByID[id] = art + s.uploadMu[id] = &sync.Mutex{} + s.mu.Unlock() + + if err := os.MkdirAll(filepath.Dir(blobPath), 0o755); err != nil { + writeTwirpError(w, http.StatusInternalServerError, "internal", "failed to create storage directory") + return + } + + uploadURL := s.makeSignedURL("PUT", id) + + writeJSON(w, http.StatusOK, MigrateArtifactResponse{ + Ok: true, + SignedUploadURL: uploadURL, + }) +} + +func (s *Server) handleFinalizeMigratedArtifact(w http.ResponseWriter, r *http.Request, runID string) { + var req FinalizeMigratedArtifactRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid JSON") + return + } + if req.WorkflowRunBackendID == "" { + req.WorkflowRunBackendID = runID + } + + key := req.WorkflowRunBackendID + "/" + req.Name + + s.mu.Lock() + art, ok := s.artifacts[key] + if !ok { + s.mu.Unlock() + writeTwirpError(w, http.StatusNotFound, "not_found", fmt.Sprintf("artifact %q not found", req.Name)) + return + } + + size, _ := strconv.ParseInt(req.Size, 10, 64) + art.Size = size + art.Finalized = true + s.mu.Unlock() + + writeJSON(w, http.StatusOK, FinalizeMigratedArtifactResponse{ + Ok: true, + ArtifactID: strconv.FormatInt(art.ID, 10), + }) +} + +// --- Blob upload/download --- + +func (s *Server) handleBlobUpload(w http.ResponseWriter, r *http.Request) { + idStr := r.PathValue("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + http.Error(w, "invalid artifact ID", http.StatusBadRequest) + return + } + + if err := s.verifySignedURL(r, "PUT", id); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + + s.mu.RLock() + mu, ok := s.uploadMu[id] + s.mu.RUnlock() + if !ok { + http.Error(w, "artifact not found", http.StatusNotFound) + return + } + + comp := r.URL.Query().Get("comp") + s.mu.RLock() + blobPath := s.getBlobPath(id) + s.mu.RUnlock() + + switch comp { + case "block": + mu.Lock() + defer mu.Unlock() + f, err := os.OpenFile(blobPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + http.Error(w, "storage error", http.StatusInternalServerError) + return + } + defer f.Close() + if _, err := io.Copy(f, r.Body); err != nil { + http.Error(w, "storage error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + + case "blocklist": + w.WriteHeader(http.StatusCreated) + + default: + mu.Lock() + defer mu.Unlock() + f, err := os.OpenFile(blobPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + if err != nil { + http.Error(w, "storage error", http.StatusInternalServerError) + return + } + defer f.Close() + if _, err := io.Copy(f, r.Body); err != nil { + http.Error(w, "storage error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + } +} + +func (s *Server) handleBlobDownload(w http.ResponseWriter, r *http.Request) { + idStr := r.PathValue("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + http.Error(w, "invalid artifact ID", http.StatusBadRequest) + return + } + + if err := s.verifySignedURL(r, "GET", id); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + + s.mu.RLock() + blobPath := s.getBlobPath(id) + s.mu.RUnlock() + f, err := os.Open(blobPath) + if err != nil { + http.Error(w, "blob not found", http.StatusNotFound) + return + } + defer f.Close() + + w.Header().Set("Content-Type", "application/octet-stream") + io.Copy(w, f) +} + +// --- Signed URL creation and verification --- + +func (s *Server) makeSignedURL(method string, artifactID int64) string { + exp := time.Now().Add(signedURLTTL).Unix() + sig := s.computeSignature(method, artifactID, exp) + + var pathPrefix string + if method == "PUT" { + pathPrefix = "upload" + } else { + pathPrefix = "download" + } + + return fmt.Sprintf("%s/%s/%d?sig=%s&exp=%d", s.externalURL, pathPrefix, artifactID, sig, exp) +} + +func (s *Server) computeSignature(method string, artifactID int64, exp int64) string { + msg := fmt.Sprintf("%s:%d:%d", method, artifactID, exp) + mac := hmac.New(sha256.New, s.signingKey) + mac.Write([]byte(msg)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func (s *Server) verifySignedURL(r *http.Request, method string, artifactID int64) error { + sig := r.URL.Query().Get("sig") + expStr := r.URL.Query().Get("exp") + if sig == "" || expStr == "" { + return fmt.Errorf("missing signature parameters") + } + exp, err := strconv.ParseInt(expStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid expiry") + } + if time.Now().Unix() > exp { + return fmt.Errorf("signed URL expired") + } + expected := s.computeSignature(method, artifactID, exp) + if !hmac.Equal([]byte(sig), []byte(expected)) { + return fmt.Errorf("invalid signature") + } + return nil +} + +// --- JWT parsing --- + +func parseJWT(authHeader string) (runID, jobID string, err error) { + if authHeader == "" { + return "", "", fmt.Errorf("missing Authorization header") + } + token := strings.TrimPrefix(authHeader, "Bearer ") + if token == authHeader { + return "", "", fmt.Errorf("invalid Authorization header format") + } + + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "", "", fmt.Errorf("invalid JWT format") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", "", fmt.Errorf("invalid JWT payload encoding: %w", err) + } + + var claims struct { + Scp string `json:"scp"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return "", "", fmt.Errorf("invalid JWT payload: %w", err) + } + + // Look for Actions.Results:{runId}:{jobId} + for _, scope := range strings.Fields(claims.Scp) { + if strings.HasPrefix(scope, "Actions.Results:") { + parts := strings.SplitN(scope, ":", 3) + if len(parts) == 3 { + return parts[1], parts[2], nil + } + if len(parts) == 2 { + return parts[1], "", nil + } + } + } + + return "", "", fmt.Errorf("no Actions.Results scope in JWT") +} + +// --- JSON helpers --- + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func writeTwirpError(w http.ResponseWriter, status int, code, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{ + "code": code, + "msg": msg, + }) +} diff --git a/github/server/server_test.go b/github/server/server_test.go new file mode 100644 index 0000000..7707ecc --- /dev/null +++ b/github/server/server_test.go @@ -0,0 +1,713 @@ +package server + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +func makeTestJWT(scp string) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) + payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"scp":"%s"}`, scp))) + return header + "." + payload + ".sig" +} + +func setupTestServer(t *testing.T) (*Server, *httptest.Server) { + t.Helper() + dir := t.TempDir() + signingKey := []byte("test-signing-key-32-bytes-long!!") + ts := httptest.NewServer(nil) + srv := NewServer(dir, signingKey, ts.URL, OIDCConfig{ + Issuer: "https://token.actions.githubusercontent.com", + Subject: "repo:local/repo:ref:refs/heads/main", + }) + ts.Config.Handler = srv + return srv, ts +} + +func twirpRequest(t *testing.T, ts *httptest.Server, method string, token string, body interface{}) *http.Response { + t.Helper() + b, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + url := ts.URL + "/twirp/github.actions.results.api.v1.ArtifactService/" + method + req, err := http.NewRequest("POST", url, bytes.NewReader(b)) + if err != nil { + t.Fatalf("create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + return resp +} + +func decodeResponse[T any](t *testing.T, resp *http.Response) T { + t.Helper() + defer resp.Body.Close() + var v T + if err := json.NewDecoder(resp.Body).Decode(&v); err != nil { + t.Fatalf("decode response: %v", err) + } + return v +} + +// --- Full upload/download/delete cycle --- + +func TestFullCycle(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + token := makeTestJWT("Actions.Results:run1:job1") + blobContent := []byte("hello artifact world") + + // 1. CreateArtifact + resp := twirpRequest(t, ts, "CreateArtifact", token, map[string]interface{}{ + "workflow_run_backend_id": "run1", + "workflow_job_run_backend_id": "job1", + "name": "my-artifact", + "version": 4, + }) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("CreateArtifact status=%d body=%s", resp.StatusCode, body) + } + createResp := decodeResponse[CreateArtifactResponse](t, resp) + if !createResp.Ok { + t.Fatal("CreateArtifact ok=false") + } + if createResp.SignedUploadURL == "" { + t.Fatal("CreateArtifact missing signed_upload_url") + } + + // 2. Upload blob (simple PUT) + req, _ := http.NewRequest("PUT", createResp.SignedUploadURL, bytes.NewReader(blobContent)) + uploadResp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("upload blob: %v", err) + } + uploadResp.Body.Close() + if uploadResp.StatusCode != http.StatusCreated { + t.Fatalf("upload blob status=%d", uploadResp.StatusCode) + } + + // 3. FinalizeArtifact + resp = twirpRequest(t, ts, "FinalizeArtifact", token, map[string]interface{}{ + "workflow_run_backend_id": "run1", + "workflow_job_run_backend_id": "job1", + "name": "my-artifact", + "size": strconv.Itoa(len(blobContent)), + }) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("FinalizeArtifact status=%d body=%s", resp.StatusCode, body) + } + finalizeResp := decodeResponse[FinalizeArtifactResponse](t, resp) + if !finalizeResp.Ok { + t.Fatal("FinalizeArtifact ok=false") + } + if finalizeResp.ArtifactID == "" { + t.Fatal("FinalizeArtifact missing artifact_id") + } + + // 4. ListArtifacts + resp = twirpRequest(t, ts, "ListArtifacts", token, map[string]interface{}{ + "workflow_run_backend_id": "run1", + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("ListArtifacts status=%d", resp.StatusCode) + } + listResp := decodeResponse[ListArtifactsResponse](t, resp) + if len(listResp.Artifacts) != 1 { + t.Fatalf("ListArtifacts expected 1, got %d", len(listResp.Artifacts)) + } + if listResp.Artifacts[0].Name != "my-artifact" { + t.Fatalf("ListArtifacts name=%q", listResp.Artifacts[0].Name) + } + if listResp.Artifacts[0].Size != strconv.Itoa(len(blobContent)) { + t.Fatalf("ListArtifacts size=%q", listResp.Artifacts[0].Size) + } + + // 5. GetSignedArtifactURL + resp = twirpRequest(t, ts, "GetSignedArtifactURL", token, map[string]interface{}{ + "workflow_run_backend_id": "run1", + "name": "my-artifact", + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetSignedArtifactURL status=%d", resp.StatusCode) + } + urlResp := decodeResponse[GetSignedArtifactURLResponse](t, resp) + if urlResp.SignedURL == "" { + t.Fatal("GetSignedArtifactURL missing signed_url") + } + + // 6. Download blob + dlResp, err := http.Get(urlResp.SignedURL) + if err != nil { + t.Fatalf("download blob: %v", err) + } + defer dlResp.Body.Close() + if dlResp.StatusCode != http.StatusOK { + t.Fatalf("download blob status=%d", dlResp.StatusCode) + } + downloaded, _ := io.ReadAll(dlResp.Body) + if !bytes.Equal(downloaded, blobContent) { + t.Fatalf("downloaded content mismatch: got %q", downloaded) + } + + // 7. DeleteArtifact + resp = twirpRequest(t, ts, "DeleteArtifact", token, map[string]interface{}{ + "workflow_run_backend_id": "run1", + "name": "my-artifact", + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("DeleteArtifact status=%d", resp.StatusCode) + } + deleteResp := decodeResponse[DeleteArtifactResponse](t, resp) + if !deleteResp.Ok { + t.Fatal("DeleteArtifact ok=false") + } + + // Verify artifact is gone + resp = twirpRequest(t, ts, "ListArtifacts", token, map[string]interface{}{ + "workflow_run_backend_id": "run1", + }) + listResp = decodeResponse[ListArtifactsResponse](t, resp) + if len(listResp.Artifacts) != 0 { + t.Fatalf("expected 0 artifacts after delete, got %d", len(listResp.Artifacts)) + } +} + +// --- Chunked upload (comp=block + comp=blocklist) --- + +func TestChunkedUpload(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + token := makeTestJWT("Actions.Results:run2:job2") + + // Create artifact + resp := twirpRequest(t, ts, "CreateArtifact", token, map[string]interface{}{ + "workflow_run_backend_id": "run2", + "workflow_job_run_backend_id": "job2", + "name": "chunked-artifact", + "version": 4, + }) + createResp := decodeResponse[CreateArtifactResponse](t, resp) + if !createResp.Ok { + t.Fatal("CreateArtifact ok=false") + } + + uploadURL := createResp.SignedUploadURL + chunk1 := []byte("chunk-one-") + chunk2 := []byte("chunk-two-") + chunk3 := []byte("chunk-three") + + // Upload blocks + for i, chunk := range [][]byte{chunk1, chunk2, chunk3} { + blockID := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("block%d", i))) + u := uploadURL + "&comp=block&blockid=" + url.QueryEscape(blockID) + req, _ := http.NewRequest("PUT", u, bytes.NewReader(chunk)) + r, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("upload block %d: %v", i, err) + } + r.Body.Close() + if r.StatusCode != http.StatusCreated { + t.Fatalf("upload block %d status=%d", i, r.StatusCode) + } + } + + // Commit block list + blockListXML := `YmxvY2swYmxvY2sxYmxvY2sy` + commitURL := uploadURL + "&comp=blocklist" + req, _ := http.NewRequest("PUT", commitURL, strings.NewReader(blockListXML)) + r, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("commit blocklist: %v", err) + } + r.Body.Close() + if r.StatusCode != http.StatusCreated { + t.Fatalf("commit blocklist status=%d", r.StatusCode) + } + + // Finalize + totalSize := len(chunk1) + len(chunk2) + len(chunk3) + resp = twirpRequest(t, ts, "FinalizeArtifact", token, map[string]interface{}{ + "workflow_run_backend_id": "run2", + "workflow_job_run_backend_id": "job2", + "name": "chunked-artifact", + "size": strconv.Itoa(totalSize), + }) + finalizeResp := decodeResponse[FinalizeArtifactResponse](t, resp) + if !finalizeResp.Ok { + t.Fatal("FinalizeArtifact ok=false") + } + + // Download and verify + resp = twirpRequest(t, ts, "GetSignedArtifactURL", token, map[string]interface{}{ + "workflow_run_backend_id": "run2", + "name": "chunked-artifact", + }) + urlResp := decodeResponse[GetSignedArtifactURLResponse](t, resp) + dlResp, _ := http.Get(urlResp.SignedURL) + defer dlResp.Body.Close() + data, _ := io.ReadAll(dlResp.Body) + expected := append(append(chunk1, chunk2...), chunk3...) + if !bytes.Equal(data, expected) { + t.Fatalf("chunked data mismatch: got %q, want %q", data, expected) + } +} + +// --- Signed URL expiry --- + +func TestSignedURLExpiry(t *testing.T) { + srv, ts := setupTestServer(t) + defer ts.Close() + + // Create an expired signed URL manually + expiredSig := srv.computeSignature("PUT", 999, time.Now().Add(-1*time.Hour).Unix()) + expiredURL := fmt.Sprintf("%s/upload/999?sig=%s&exp=%d", ts.URL, expiredSig, time.Now().Add(-1*time.Hour).Unix()) + + req, _ := http.NewRequest("PUT", expiredURL, strings.NewReader("data")) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403 for expired URL, got %d", resp.StatusCode) + } +} + +func TestSignedURLInvalidSignature(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + badURL := fmt.Sprintf("%s/upload/1?sig=badsig&exp=%d", ts.URL, time.Now().Add(1*time.Hour).Unix()) + req, _ := http.NewRequest("PUT", badURL, strings.NewReader("data")) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403 for invalid sig, got %d", resp.StatusCode) + } +} + +func TestSignedURLMissingParams(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + req, _ := http.NewRequest("PUT", ts.URL+"/upload/1", strings.NewReader("data")) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403 for missing params, got %d", resp.StatusCode) + } +} + +// --- JWT parsing --- + +func TestParseJWT(t *testing.T) { + tests := []struct { + name string + auth string + wantRun string + wantJob string + wantErr bool + }{ + { + name: "valid JWT", + auth: "Bearer " + makeTestJWT("Actions.Results:run1:job1"), + wantRun: "run1", + wantJob: "job1", + }, + { + name: "multiple scopes", + auth: "Bearer " + makeTestJWT("Actions.Read Actions.Results:runX:jobY Actions.Write"), + wantRun: "runX", + wantJob: "jobY", + }, + { + name: "missing auth", + auth: "", + wantErr: true, + }, + { + name: "no Bearer prefix", + auth: "Token abc", + wantErr: true, + }, + { + name: "invalid JWT structure", + auth: "Bearer not.a.valid-base64!", + wantErr: true, + }, + { + name: "JWT without scope", + auth: "Bearer " + makeTestJWT("Actions.Read"), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runID, jobID, err := parseJWT(tt.auth) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if runID != tt.wantRun { + t.Fatalf("runID=%q, want %q", runID, tt.wantRun) + } + if jobID != tt.wantJob { + t.Fatalf("jobID=%q, want %q", jobID, tt.wantJob) + } + }) + } +} + +// --- Error responses --- + +func TestErrorNotFound(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + token := makeTestJWT("Actions.Results:run1:job1") + + // FinalizeArtifact for nonexistent artifact + resp := twirpRequest(t, ts, "FinalizeArtifact", token, map[string]interface{}{ + "workflow_run_backend_id": "run1", + "workflow_job_run_backend_id": "job1", + "name": "nonexistent", + "size": "0", + }) + if resp.StatusCode != http.StatusNotFound { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + t.Fatalf("expected 404, got %d: %s", resp.StatusCode, body) + } + resp.Body.Close() + + // GetSignedArtifactURL for nonexistent + resp = twirpRequest(t, ts, "GetSignedArtifactURL", token, map[string]interface{}{ + "workflow_run_backend_id": "run1", + "name": "nonexistent", + }) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } + resp.Body.Close() + + // DeleteArtifact for nonexistent + resp = twirpRequest(t, ts, "DeleteArtifact", token, map[string]interface{}{ + "workflow_run_backend_id": "run1", + "name": "nonexistent", + }) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestDuplicateArtifact(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + token := makeTestJWT("Actions.Results:run1:job1") + + // Create and finalize + resp := twirpRequest(t, ts, "CreateArtifact", token, map[string]interface{}{ + "workflow_run_backend_id": "run1", + "workflow_job_run_backend_id": "job1", + "name": "dup-artifact", + "version": 4, + }) + createResp := decodeResponse[CreateArtifactResponse](t, resp) + if !createResp.Ok { + t.Fatal("first CreateArtifact failed") + } + + resp = twirpRequest(t, ts, "FinalizeArtifact", token, map[string]interface{}{ + "workflow_run_backend_id": "run1", + "workflow_job_run_backend_id": "job1", + "name": "dup-artifact", + "size": "0", + }) + finalizeResp := decodeResponse[FinalizeArtifactResponse](t, resp) + if !finalizeResp.Ok { + t.Fatal("FinalizeArtifact failed") + } + + // Try to create again -> should fail + resp = twirpRequest(t, ts, "CreateArtifact", token, map[string]interface{}{ + "workflow_run_backend_id": "run1", + "workflow_job_run_backend_id": "job1", + "name": "dup-artifact", + "version": 4, + }) + if resp.StatusCode != http.StatusConflict { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + t.Fatalf("expected 409 for duplicate, got %d: %s", resp.StatusCode, body) + } + resp.Body.Close() +} + +func TestInvalidAuth(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + // No auth header + b, _ := json.Marshal(map[string]interface{}{ + "workflow_run_backend_id": "run1", + }) + req, _ := http.NewRequest("POST", + ts.URL+"/twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts", + bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } +} + +func TestInvalidContentType(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + req, _ := http.NewRequest("POST", + ts.URL+"/twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts", + strings.NewReader("{}")) + req.Header.Set("Content-Type", "text/plain") + req.Header.Set("Authorization", "Bearer "+makeTestJWT("Actions.Results:run1:job1")) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} + +func TestUnknownMethod(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + token := makeTestJWT("Actions.Results:run1:job1") + resp := twirpRequest(t, ts, "UnknownMethod", token, map[string]string{}) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +// --- ListArtifacts filtering --- + +func TestListArtifactsFilters(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + token := makeTestJWT("Actions.Results:runF:jobF") + + // Create and finalize two artifacts + for _, name := range []string{"alpha", "beta"} { + resp := twirpRequest(t, ts, "CreateArtifact", token, map[string]interface{}{ + "workflow_run_backend_id": "runF", + "workflow_job_run_backend_id": "jobF", + "name": name, + "version": 4, + }) + decodeResponse[CreateArtifactResponse](t, resp) + + resp = twirpRequest(t, ts, "FinalizeArtifact", token, map[string]interface{}{ + "workflow_run_backend_id": "runF", + "workflow_job_run_backend_id": "jobF", + "name": name, + "size": "100", + }) + decodeResponse[FinalizeArtifactResponse](t, resp) + } + + // Filter by name + resp := twirpRequest(t, ts, "ListArtifacts", token, map[string]interface{}{ + "workflow_run_backend_id": "runF", + "name_filter": "alpha", + }) + listResp := decodeResponse[ListArtifactsResponse](t, resp) + if len(listResp.Artifacts) != 1 || listResp.Artifacts[0].Name != "alpha" { + t.Fatalf("name filter: got %+v", listResp.Artifacts) + } + + // Unfinalized artifacts should not appear + resp = twirpRequest(t, ts, "CreateArtifact", token, map[string]interface{}{ + "workflow_run_backend_id": "runF", + "workflow_job_run_backend_id": "jobF", + "name": "gamma", + "version": 4, + }) + decodeResponse[CreateArtifactResponse](t, resp) + + resp = twirpRequest(t, ts, "ListArtifacts", token, map[string]interface{}{ + "workflow_run_backend_id": "runF", + }) + listResp = decodeResponse[ListArtifactsResponse](t, resp) + if len(listResp.Artifacts) != 2 { + t.Fatalf("expected 2 finalized artifacts, got %d", len(listResp.Artifacts)) + } +} + +// --- Migrate artifact flow --- + +func TestMigrateArtifact(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + token := makeTestJWT("Actions.Results:runM:jobM") + blobContent := []byte("migrated data") + + // MigrateArtifact + resp := twirpRequest(t, ts, "MigrateArtifact", token, map[string]interface{}{ + "workflow_run_backend_id": "runM", + "name": "migrated-artifact", + "version": 4, + }) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("MigrateArtifact status=%d body=%s", resp.StatusCode, body) + } + migrateResp := decodeResponse[MigrateArtifactResponse](t, resp) + if !migrateResp.Ok { + t.Fatal("MigrateArtifact ok=false") + } + + // Upload blob + req, _ := http.NewRequest("PUT", migrateResp.SignedUploadURL, bytes.NewReader(blobContent)) + uploadResp, _ := http.DefaultClient.Do(req) + uploadResp.Body.Close() + if uploadResp.StatusCode != http.StatusCreated { + t.Fatalf("upload status=%d", uploadResp.StatusCode) + } + + // FinalizeMigratedArtifact + resp = twirpRequest(t, ts, "FinalizeMigratedArtifact", token, map[string]interface{}{ + "workflow_run_backend_id": "runM", + "name": "migrated-artifact", + "size": strconv.Itoa(len(blobContent)), + }) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("FinalizeMigratedArtifact status=%d body=%s", resp.StatusCode, body) + } + finalResp := decodeResponse[FinalizeMigratedArtifactResponse](t, resp) + if !finalResp.Ok { + t.Fatal("FinalizeMigratedArtifact ok=false") + } + + // Download and verify + resp = twirpRequest(t, ts, "GetSignedArtifactURL", token, map[string]interface{}{ + "workflow_run_backend_id": "runM", + "name": "migrated-artifact", + }) + urlResp := decodeResponse[GetSignedArtifactURLResponse](t, resp) + dlResp, _ := http.Get(urlResp.SignedURL) + defer dlResp.Body.Close() + data, _ := io.ReadAll(dlResp.Body) + if !bytes.Equal(data, blobContent) { + t.Fatalf("migrated content mismatch: got %q", data) + } +} + +// --- Download nonexistent blob --- + +func TestDownloadNonexistentBlob(t *testing.T) { + srv, ts := setupTestServer(t) + defer ts.Close() + + // Craft a valid signed URL for a nonexistent blob + exp := time.Now().Add(1 * time.Hour).Unix() + sig := srv.computeSignature("GET", 999, exp) + dlURL := fmt.Sprintf("%s/download/999?sig=%s&exp=%d", ts.URL, sig, exp) + resp, _ := http.Get(dlURL) + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 for nonexistent blob, got %d", resp.StatusCode) + } +} + +// --- Blob deletion removes file --- + +func TestDeleteRemovesBlobFile(t *testing.T) { + srv, ts := setupTestServer(t) + defer ts.Close() + + token := makeTestJWT("Actions.Results:runD:jobD") + blobContent := []byte("to be deleted") + + // Create + upload + finalize + resp := twirpRequest(t, ts, "CreateArtifact", token, map[string]interface{}{ + "workflow_run_backend_id": "runD", + "workflow_job_run_backend_id": "jobD", + "name": "del-artifact", + "version": 4, + }) + createResp := decodeResponse[CreateArtifactResponse](t, resp) + req, _ := http.NewRequest("PUT", createResp.SignedUploadURL, bytes.NewReader(blobContent)) + r, _ := http.DefaultClient.Do(req) + r.Body.Close() + + resp = twirpRequest(t, ts, "FinalizeArtifact", token, map[string]interface{}{ + "workflow_run_backend_id": "runD", + "workflow_job_run_backend_id": "jobD", + "name": "del-artifact", + "size": strconv.Itoa(len(blobContent)), + }) + decodeResponse[FinalizeArtifactResponse](t, resp) + + // Check blob file exists + blobPath := filepath.Join(srv.storageDir, "runD", "del-artifact") + if _, err := os.Stat(blobPath); err != nil { + t.Fatalf("blob file should exist: %v", err) + } + + // Delete + resp = twirpRequest(t, ts, "DeleteArtifact", token, map[string]interface{}{ + "workflow_run_backend_id": "runD", + "name": "del-artifact", + }) + decodeResponse[DeleteArtifactResponse](t, resp) + + // Check blob file is gone + if _, err := os.Stat(blobPath); !os.IsNotExist(err) { + t.Fatal("blob file should be deleted") + } +} diff --git a/github/server/start.go b/github/server/start.go new file mode 100644 index 0000000..c5aff29 --- /dev/null +++ b/github/server/start.go @@ -0,0 +1,100 @@ +package server + +import ( + "crypto/rand" + "fmt" + "net" + "net/http" + "os" +) + +// Config holds parameters for starting a local GitHub Actions server. +type Config struct { + StorageDir string // Directory for blob storage (created if missing) + OIDCIssuer string // OIDC token issuer (defaults to GitHub's) + OIDCSub string // OIDC token subject (defaults to repo:local/repo:ref:refs/heads/main) +} + +// RunningServer represents a started server instance. +type RunningServer struct { + URL string // Base URL of the server (e.g. http://127.0.0.1:12345) + RuntimeToken string // JWT token for ACTIONS_RUNTIME_TOKEN + httpServer *http.Server +} + +// Stop gracefully shuts down the server. +func (rs *RunningServer) Stop() { + rs.httpServer.Close() +} + +// InjectEnv sets GitHub Actions environment variables into the given env map. +func (rs *RunningServer) InjectEnv(env map[string]string) { + env["ACTIONS_RUNTIME_URL"] = rs.URL + "/" + env["ACTIONS_RUNTIME_TOKEN"] = rs.RuntimeToken + env["ACTIONS_CACHE_URL"] = rs.URL + "/" + env["ACTIONS_RESULTS_URL"] = rs.URL + "/" + env["ACTIONS_ID_TOKEN_REQUEST_URL"] = rs.URL + "/_services/token" + env["ACTIONS_ID_TOKEN_REQUEST_TOKEN"] = rs.RuntimeToken +} + +// StartServer starts a local GitHub Actions mock server on a random port. +func StartServer(cfg Config) (*RunningServer, error) { + if cfg.StorageDir == "" { + cfg.StorageDir = os.TempDir() + } + if cfg.OIDCIssuer == "" { + cfg.OIDCIssuer = "https://token.actions.githubusercontent.com" + } + if cfg.OIDCSub == "" { + cfg.OIDCSub = "repo:local/repo:ref:refs/heads/main" + } + + if err := os.MkdirAll(cfg.StorageDir, 0o755); err != nil { + return nil, fmt.Errorf("creating storage directory: %w", err) + } + + // Generate random HMAC signing key + signingKey := make([]byte, 32) + if _, err := rand.Read(signingKey); err != nil { + return nil, fmt.Errorf("generating signing key: %w", err) + } + + // Listen on random port + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("listening: %w", err) + } + + externalURL := fmt.Sprintf("http://%s", listener.Addr().String()) + + srv := NewServer(cfg.StorageDir, signingKey, externalURL, OIDCConfig{ + Issuer: cfg.OIDCIssuer, + Subject: cfg.OIDCSub, + }) + + httpServer := &http.Server{ + Handler: srv, + } + + // Generate a minimal runtime token (JWT with Actions.Results scope) + runtimeToken, err := makeRuntimeToken(signingKey) + if err != nil { + listener.Close() + return nil, fmt.Errorf("generating runtime token: %w", err) + } + + go httpServer.Serve(listener) + + return &RunningServer{ + URL: externalURL, + RuntimeToken: runtimeToken, + httpServer: httpServer, + }, nil +} + +// makeRuntimeToken creates a minimal JWT with an Actions.Results scope. +func makeRuntimeToken(signingKey []byte) (string, error) { + return mintJWT(signingKey, map[string]any{ + "scp": "Actions.Results:local-run:local-job", + }) +} diff --git a/github/server/start_test.go b/github/server/start_test.go new file mode 100644 index 0000000..c8fdaf3 --- /dev/null +++ b/github/server/start_test.go @@ -0,0 +1,115 @@ +package server + +import ( + "net/http" + "strings" + "testing" +) + +func TestStartServerAndStop(t *testing.T) { + rs, err := StartServer(Config{ + StorageDir: t.TempDir(), + }) + if err != nil { + t.Fatalf("StartServer: %v", err) + } + defer rs.Stop() + + if rs.URL == "" { + t.Fatal("URL is empty") + } + if rs.RuntimeToken == "" { + t.Fatal("RuntimeToken is empty") + } + if !strings.HasPrefix(rs.URL, "http://127.0.0.1:") { + t.Fatalf("unexpected URL: %s", rs.URL) + } + + // Verify server is reachable + resp, err := http.Get(rs.URL + "/_services/token") + if err != nil { + t.Fatalf("server not reachable: %v", err) + } + resp.Body.Close() + // 401 is expected (no auth header) but it confirms the server is running + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } +} + +func TestInjectEnv(t *testing.T) { + rs, err := StartServer(Config{ + StorageDir: t.TempDir(), + }) + if err != nil { + t.Fatalf("StartServer: %v", err) + } + defer rs.Stop() + + env := make(map[string]string) + rs.InjectEnv(env) + + expected := []string{ + "ACTIONS_RUNTIME_URL", + "ACTIONS_RUNTIME_TOKEN", + "ACTIONS_CACHE_URL", + "ACTIONS_RESULTS_URL", + "ACTIONS_ID_TOKEN_REQUEST_URL", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN", + } + for _, key := range expected { + if env[key] == "" { + t.Errorf("missing env var: %s", key) + } + } + + if env["ACTIONS_RUNTIME_URL"] != rs.URL+"/" { + t.Errorf("ACTIONS_RUNTIME_URL=%q, want %q", env["ACTIONS_RUNTIME_URL"], rs.URL+"/") + } + if env["ACTIONS_RUNTIME_TOKEN"] != rs.RuntimeToken { + t.Error("ACTIONS_RUNTIME_TOKEN mismatch") + } + if env["ACTIONS_ID_TOKEN_REQUEST_URL"] != rs.URL+"/_services/token" { + t.Errorf("ACTIONS_ID_TOKEN_REQUEST_URL=%q", env["ACTIONS_ID_TOKEN_REQUEST_URL"]) + } +} + +func TestStartServerDefaultConfig(t *testing.T) { + rs, err := StartServer(Config{ + StorageDir: t.TempDir(), + }) + if err != nil { + t.Fatalf("StartServer: %v", err) + } + defer rs.Stop() + + // Verify runtime token is a valid JWT that the server accepts + req, _ := http.NewRequest("GET", rs.URL+"/_services/token", nil) + req.Header.Set("Authorization", "Bearer "+rs.RuntimeToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 with valid token, got %d", resp.StatusCode) + } +} + +func TestStopMakesServerUnreachable(t *testing.T) { + rs, err := StartServer(Config{ + StorageDir: t.TempDir(), + }) + if err != nil { + t.Fatalf("StartServer: %v", err) + } + + url := rs.URL + rs.Stop() + + // Server should no longer be reachable + _, err = http.Get(url + "/_services/token") + if err == nil { + t.Fatal("expected error after Stop, server still reachable") + } +} From 6d9cd4ab97e2c75d0aa48c05f373eddef22a4791 Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Sat, 14 Feb 2026 11:48:35 -0500 Subject: [PATCH 2/9] Add FlexInt64 type for JSON unmarshalling and enhance cache entry responses --- github/server/cache.go | 33 +++++++++++++-- github/server/cache_test.go | 81 +++++++++++++++++++++++++++++++++++++ github/server/server.go | 49 ++++++++++++++-------- github/server/start.go | 2 + 4 files changed, 144 insertions(+), 21 deletions(-) diff --git a/github/server/cache.go b/github/server/cache.go index c11e936..a0093e1 100644 --- a/github/server/cache.go +++ b/github/server/cache.go @@ -68,10 +68,32 @@ type CreateCacheEntryResponse struct { SignedUploadURL string `json:"signed_upload_url"` } +// FlexInt64 unmarshals from both JSON numbers and JSON strings. +// Protobuf's canonical JSON encoding represents int64 as strings. +type FlexInt64 int64 + +func (f *FlexInt64) UnmarshalJSON(data []byte) error { + var n int64 + if err := json.Unmarshal(data, &n); err == nil { + *f = FlexInt64(n) + return nil + } + var s string + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("FlexInt64: cannot unmarshal %s", string(data)) + } + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return fmt.Errorf("FlexInt64: invalid int64 string %q: %w", s, err) + } + *f = FlexInt64(n) + return nil +} + type FinalizeCacheEntryRequest struct { - Key string `json:"key"` - Version string `json:"version"` - SizeBytes int64 `json:"size_bytes"` + Key string `json:"key"` + Version string `json:"version"` + SizeBytes FlexInt64 `json:"size_bytes"` } type FinalizeCacheEntryResponse struct { @@ -89,6 +111,7 @@ type GetCacheEntryDownloadURLRequest struct { type GetCacheEntryDownloadURLResponse struct { Ok bool `json:"ok"` SignedDownloadURL string `json:"signed_download_url"` + MatchedKey string `json:"matched_key"` } type DeleteCacheEntryRequest struct { @@ -166,7 +189,7 @@ func (s *Server) handleFinalizeCacheEntry(w http.ResponseWriter, r *http.Request writeTwirpError(w, http.StatusNotFound, "not_found", "cache entry not found") return } - found.Size = req.SizeBytes + found.Size = int64(req.SizeBytes) found.Finalized = true s.mu.Unlock() @@ -198,6 +221,7 @@ func (s *Server) handleGetCacheEntryDownloadURL(w http.ResponseWriter, r *http.R writeJSON(w, http.StatusOK, GetCacheEntryDownloadURLResponse{ Ok: true, SignedDownloadURL: downloadURL, + MatchedKey: entry.Key, }) return } @@ -224,6 +248,7 @@ func (s *Server) handleGetCacheEntryDownloadURL(w http.ResponseWriter, r *http.R writeJSON(w, http.StatusOK, GetCacheEntryDownloadURLResponse{ Ok: true, SignedDownloadURL: downloadURL, + MatchedKey: best.Key, }) return } diff --git a/github/server/cache_test.go b/github/server/cache_test.go index 0091489..78a83e4 100644 --- a/github/server/cache_test.go +++ b/github/server/cache_test.go @@ -249,6 +249,87 @@ func TestCacheInvalidAuth(t *testing.T) { } } +func TestCacheSizeBytesAsString(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + token := makeTestJWT("Actions.Results:run1:job1") + + // Create cache entry + resp := cacheTwirpRequest(t, ts, "CreateCacheEntry", token, map[string]any{ + "key": "str-size-test", + "version": "v1", + }) + createResp := decodeResponse[CreateCacheEntryResponse](t, resp) + req, _ := http.NewRequest("PUT", createResp.SignedUploadURL, bytes.NewReader([]byte("data"))) + r, _ := http.DefaultClient.Do(req) + r.Body.Close() + + // Finalize with size_bytes as a JSON string (protobuf int64 encoding) + b, _ := json.Marshal(map[string]any{ + "key": "str-size-test", + "version": "v1", + "size_bytes": "4", + }) + url := ts.URL + "/twirp/github.actions.results.api.v1.CacheService/FinalizeCacheEntryUpload" + httpReq, _ := http.NewRequest("POST", url, bytes.NewReader(b)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + t.Fatalf("request: %v", err) + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("FinalizeCacheEntryUpload with string size_bytes: status=%d body=%s", resp.StatusCode, body) + } + finalResp := decodeResponse[FinalizeCacheEntryResponse](t, resp) + if !finalResp.Ok { + t.Fatal("finalize failed") + } +} + +func TestCacheMatchedKey(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + token := makeTestJWT("Actions.Results:run1:job1") + + // Create, upload, finalize + resp := cacheTwirpRequest(t, ts, "CreateCacheEntry", token, map[string]any{ + "key": "my-key-abc", + "version": "v1", + }) + createResp := decodeResponse[CreateCacheEntryResponse](t, resp) + req, _ := http.NewRequest("PUT", createResp.SignedUploadURL, bytes.NewReader([]byte("data"))) + r, _ := http.DefaultClient.Do(req) + r.Body.Close() + cacheTwirpRequest(t, ts, "FinalizeCacheEntryUpload", token, map[string]any{ + "key": "my-key-abc", "version": "v1", "size_bytes": 4, + }) + + // Exact match — matched_key should be the key + resp = cacheTwirpRequest(t, ts, "GetCacheEntryDownloadURL", token, map[string]any{ + "key": "my-key-abc", + "version": "v1", + }) + dlResp := decodeResponse[GetCacheEntryDownloadURLResponse](t, resp) + if dlResp.MatchedKey != "my-key-abc" { + t.Fatalf("exact match: matched_key=%q, want %q", dlResp.MatchedKey, "my-key-abc") + } + + // Prefix match via restore_keys — matched_key should be the matched entry's key + resp = cacheTwirpRequest(t, ts, "GetCacheEntryDownloadURL", token, map[string]any{ + "key": "my-key-xyz", + "version": "v1", + "restore_keys": []string{"my-key-"}, + }) + dlResp = decodeResponse[GetCacheEntryDownloadURLResponse](t, resp) + if dlResp.MatchedKey != "my-key-abc" { + t.Fatalf("prefix match: matched_key=%q, want %q", dlResp.MatchedKey, "my-key-abc") + } +} + func TestCacheInvalidContentType(t *testing.T) { _, ts := setupTestServer(t) defer ts.Close() diff --git a/github/server/server.go b/github/server/server.go index df9fa7f..7c62daa 100644 --- a/github/server/server.go +++ b/github/server/server.go @@ -155,10 +155,11 @@ func (s *Server) handleTwirp(w http.ResponseWriter, r *http.Request) { // --- Request/Response types --- type CreateArtifactRequest struct { - WorkflowRunBackendID string `json:"workflow_run_backend_id"` - WorkflowJobRunBackendID string `json:"workflow_job_run_backend_id"` - Name string `json:"name"` - Version int `json:"version"` + WorkflowRunBackendID string `json:"workflow_run_backend_id"` + WorkflowJobRunBackendID string `json:"workflow_job_run_backend_id"` + Name string `json:"name"` + Version int `json:"version"` + ExpiresAt *string `json:"expires_at,omitempty"` } type CreateArtifactResponse struct { @@ -180,9 +181,10 @@ type FinalizeArtifactResponse struct { } type ListArtifactsRequest struct { - WorkflowRunBackendID string `json:"workflow_run_backend_id"` - NameFilter *string `json:"name_filter,omitempty"` - IDFilter *string `json:"id_filter,omitempty"` + WorkflowRunBackendID string `json:"workflow_run_backend_id"` + WorkflowJobRunBackendID string `json:"workflow_job_run_backend_id"` + NameFilter *string `json:"name_filter,omitempty"` + IDFilter *string `json:"id_filter,omitempty"` } type ListArtifactsResponse struct { @@ -196,11 +198,13 @@ type ArtifactEntry struct { Name string `json:"name"` Size string `json:"size"` CreatedAt *string `json:"created_at,omitempty"` + Digest *string `json:"digest,omitempty"` } type GetSignedArtifactURLRequest struct { - WorkflowRunBackendID string `json:"workflow_run_backend_id"` - Name string `json:"name"` + WorkflowRunBackendID string `json:"workflow_run_backend_id"` + WorkflowJobRunBackendID string `json:"workflow_job_run_backend_id"` + Name string `json:"name"` } type GetSignedArtifactURLResponse struct { @@ -208,8 +212,9 @@ type GetSignedArtifactURLResponse struct { } type DeleteArtifactRequest struct { - WorkflowRunBackendID string `json:"workflow_run_backend_id"` - Name string `json:"name"` + WorkflowRunBackendID string `json:"workflow_run_backend_id"` + WorkflowJobRunBackendID string `json:"workflow_job_run_backend_id"` + Name string `json:"name"` } type DeleteArtifactResponse struct { @@ -218,9 +223,9 @@ type DeleteArtifactResponse struct { } type MigrateArtifactRequest struct { - WorkflowRunBackendID string `json:"workflow_run_backend_id"` - Name string `json:"name"` - Version int `json:"version"` + WorkflowRunBackendID string `json:"workflow_run_backend_id"` + Name string `json:"name"` + ExpiresAt *string `json:"expires_at,omitempty"` } type MigrateArtifactResponse struct { @@ -280,6 +285,11 @@ func (s *Server) handleCreateArtifact(w http.ResponseWriter, r *http.Request, ru BlobPath: blobPath, CreatedAt: time.Now(), } + if req.ExpiresAt != nil { + if t, err := time.Parse(time.RFC3339, *req.ExpiresAt); err == nil { + art.ExpiresAt = t + } + } s.artifacts[key] = art s.artByID[id] = art s.uploadMu[id] = &sync.Mutex{} @@ -299,7 +309,7 @@ func (s *Server) handleCreateArtifact(w http.ResponseWriter, r *http.Request, ru }) } -func (s *Server) handleFinalizeArtifact(w http.ResponseWriter, r *http.Request, runID, jobID string) { +func (s *Server) handleFinalizeArtifact(w http.ResponseWriter, r *http.Request, runID, _ string) { var req FinalizeArtifactRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid JSON") @@ -362,14 +372,19 @@ func (s *Server) handleListArtifacts(w http.ResponseWriter, r *http.Request, run } } ts := art.CreatedAt.UTC().Format(time.RFC3339) - entries = append(entries, ArtifactEntry{ + entry := ArtifactEntry{ WorkflowRunBackendID: art.RunBackendID, WorkflowJobRunBackendID: art.JobBackendID, DatabaseID: strconv.FormatInt(art.ID, 10), Name: art.Name, Size: strconv.FormatInt(art.Size, 10), CreatedAt: &ts, - }) + } + if art.Hash != "" { + h := art.Hash + entry.Digest = &h + } + entries = append(entries, entry) } s.mu.RUnlock() diff --git a/github/server/start.go b/github/server/start.go index c5aff29..52927ab 100644 --- a/github/server/start.go +++ b/github/server/start.go @@ -35,6 +35,8 @@ func (rs *RunningServer) InjectEnv(env map[string]string) { env["ACTIONS_RESULTS_URL"] = rs.URL + "/" env["ACTIONS_ID_TOKEN_REQUEST_URL"] = rs.URL + "/_services/token" env["ACTIONS_ID_TOKEN_REQUEST_TOKEN"] = rs.RuntimeToken + // Required for @actions/cache to use the twirp v2 api instead of the legacy REST API. + env["ACTIONS_CACHE_SERVICE_V2"] = "true" } // StartServer starts a local GitHub Actions mock server on a random port. From b8bde32288f0b849201e2cb5a800bf17b0b2ee57 Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Sat, 14 Feb 2026 15:57:14 -0500 Subject: [PATCH 3/9] Run mock server when graph is being debugged --- github/server/start.go | 6 +++++- sessions/protocol.go | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/github/server/start.go b/github/server/start.go index 52927ab..ac74db7 100644 --- a/github/server/start.go +++ b/github/server/start.go @@ -85,7 +85,11 @@ func StartServer(cfg Config) (*RunningServer, error) { return nil, fmt.Errorf("generating runtime token: %w", err) } - go httpServer.Serve(listener) + go func() { + if err := httpServer.Serve(listener); err != nil { + fmt.Fprintf(os.Stderr, "server error: %v\n", err) + } + }() return &RunningServer{ URL: externalURL, diff --git a/sessions/protocol.go b/sessions/protocol.go index c01c6cf..c30a621 100644 --- a/sessions/protocol.go +++ b/sessions/protocol.go @@ -579,6 +579,7 @@ func runGraphFromConn(ctx context.Context, graphData string, opts core.RunOpts, OverrideInputs: opts.OverrideInputs, OverrideEnv: opts.OverrideEnv, Args: []string{}, + LocalGhServer: true, }, debugCb) }() From 013334bdcc22974d0a9b35cb7391b78b98abdb12 Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Sun, 15 Feb 2026 19:38:00 -0500 Subject: [PATCH 4/9] Fix codeql issues --- github/server/legacy.go | 19 +++++++++---- github/server/server.go | 59 ++++++++++++++++++++++++----------------- github/server/start.go | 8 ++++++ 3 files changed, 57 insertions(+), 29 deletions(-) diff --git a/github/server/legacy.go b/github/server/legacy.go index 29cc37a..6c8f5bd 100644 --- a/github/server/legacy.go +++ b/github/server/legacy.go @@ -9,6 +9,8 @@ import ( "path/filepath" "strconv" "strings" + + "github.com/actionforge/actrun-cli/utils" ) type ArtifactContainer struct { @@ -89,13 +91,16 @@ func (s *Server) handleLegacyUpload(w http.ResponseWriter, r *http.Request) { return } - dir := filepath.Join(s.storageDir, "v3", cidStr, filepath.Dir(itemPath)) - if err := os.MkdirAll(dir, 0o755); err != nil { - http.Error(w, "storage error", http.StatusInternalServerError) + filePath, err := utils.SafeJoinPath(s.storageDir, "v3", cidStr, itemPath) + if err != nil { + http.Error(w, "invalid item path", http.StatusBadRequest) return } - filePath := filepath.Join(s.storageDir, "v3", cidStr, itemPath) + if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { + http.Error(w, "storage error", http.StatusInternalServerError) + return + } // Parse Content-Range for chunked uploads: "bytes {start}-{end}/{total}" var start int64 @@ -261,7 +266,11 @@ func (s *Server) handleLegacyDownload(w http.ResponseWriter, r *http.Request) { return } - diskPath := filepath.Join(s.storageDir, "v3", cidStr, filePath) + diskPath, err := utils.SafeJoinPath(s.storageDir, "v3", cidStr, filePath) + if err != nil { + http.Error(w, "invalid path", http.StatusBadRequest) + return + } f, err := os.Open(diskPath) if err != nil { http.Error(w, "file not found", http.StatusNotFound) diff --git a/github/server/server.go b/github/server/server.go index 7c62daa..fb81121 100644 --- a/github/server/server.go +++ b/github/server/server.go @@ -15,38 +15,40 @@ import ( "strings" "sync" "time" + + "github.com/actionforge/actrun-cli/utils" ) // Artifact represents a stored artifact with its metadata. type Artifact struct { - ID int64 - Name string - RunBackendID string - JobBackendID string - BlobPath string // on-disk path: {storageDir}/{runBackendID}/{name} - Size int64 - Hash string - Finalized bool - CreatedAt time.Time - ExpiresAt time.Time - WorkflowName string - RunID int64 - WorkflowRunID int64 - MonolithRunID int64 + ID int64 + Name string + RunBackendID string + JobBackendID string + BlobPath string // on-disk path: {storageDir}/{runBackendID}/{name} + Size int64 + Hash string + Finalized bool + CreatedAt time.Time + ExpiresAt time.Time + WorkflowName string + RunID int64 + WorkflowRunID int64 + MonolithRunID int64 } // Server implements the GitHub Actions artifact service protocol. type Server struct { - mu sync.RWMutex + mu sync.RWMutex // Artifact v4 state - artifacts map[string]*Artifact // "{runBackendId}/{name}" - artByID map[int64]*Artifact + artifacts map[string]*Artifact // "{runBackendId}/{name}" + artByID map[int64]*Artifact // Cache state - caches map[string]*CacheEntry // "{scope}/{key}/{version}" - cacheByID map[int64]*CacheEntry + caches map[string]*CacheEntry // "{scope}/{key}/{version}" + cacheByID map[int64]*CacheEntry // Legacy artifact v3 state - containers map[string]*ArtifactContainer // "{runId}/{name}" - contByID map[int64]*ArtifactContainer + containers map[string]*ArtifactContainer // "{runId}/{name}" + contByID map[int64]*ArtifactContainer // Shared uploadMu map[int64]*sync.Mutex nextID int64 @@ -295,8 +297,12 @@ func (s *Server) handleCreateArtifact(w http.ResponseWriter, r *http.Request, ru s.uploadMu[id] = &sync.Mutex{} s.mu.Unlock() - // Ensure run subdirectory exists - if err := os.MkdirAll(filepath.Dir(blobPath), 0o755); err != nil { + safeDir, err := utils.SafeJoinPath(s.storageDir, filepath.Base(req.WorkflowRunBackendID)) + if err != nil { + writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid artifact path") + return + } + if err := os.MkdirAll(safeDir, 0o755); err != nil { writeTwirpError(w, http.StatusInternalServerError, "internal", "failed to create storage directory") return } @@ -494,7 +500,12 @@ func (s *Server) handleMigrateArtifact(w http.ResponseWriter, r *http.Request, r s.uploadMu[id] = &sync.Mutex{} s.mu.Unlock() - if err := os.MkdirAll(filepath.Dir(blobPath), 0o755); err != nil { + safeDir, err := utils.SafeJoinPath(s.storageDir, filepath.Base(req.WorkflowRunBackendID)) + if err != nil { + writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid artifact path") + return + } + if err := os.MkdirAll(safeDir, 0o755); err != nil { writeTwirpError(w, http.StatusInternalServerError, "internal", "failed to create storage directory") return } diff --git a/github/server/start.go b/github/server/start.go index ac74db7..0cabd92 100644 --- a/github/server/start.go +++ b/github/server/start.go @@ -6,6 +6,8 @@ import ( "net" "net/http" "os" + + "github.com/actionforge/actrun-cli/utils" ) // Config holds parameters for starting a local GitHub Actions server. @@ -51,6 +53,12 @@ func StartServer(cfg Config) (*RunningServer, error) { cfg.OIDCSub = "repo:local/repo:ref:refs/heads/main" } + cleanDir, err := utils.ValidatePath(cfg.StorageDir) + if err != nil { + return nil, fmt.Errorf("invalid storage directory: %w", err) + } + cfg.StorageDir = cleanDir + if err := os.MkdirAll(cfg.StorageDir, 0o755); err != nil { return nil, fmt.Errorf("creating storage directory: %w", err) } From 1c2d3c62a796c39e8ccb20209656a8f9f20d0616 Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Sun, 15 Feb 2026 19:46:37 -0500 Subject: [PATCH 5/9] Additional codeql warning fix --- github/server/start.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/github/server/start.go b/github/server/start.go index 0cabd92..a95e400 100644 --- a/github/server/start.go +++ b/github/server/start.go @@ -6,8 +6,7 @@ import ( "net" "net/http" "os" - - "github.com/actionforge/actrun-cli/utils" + "path/filepath" ) // Config holds parameters for starting a local GitHub Actions server. @@ -53,11 +52,11 @@ func StartServer(cfg Config) (*RunningServer, error) { cfg.OIDCSub = "repo:local/repo:ref:refs/heads/main" } - cleanDir, err := utils.ValidatePath(cfg.StorageDir) + absDir, err := filepath.Abs(cfg.StorageDir) if err != nil { return nil, fmt.Errorf("invalid storage directory: %w", err) } - cfg.StorageDir = cleanDir + cfg.StorageDir = absDir if err := os.MkdirAll(cfg.StorageDir, 0o755); err != nil { return nil, fmt.Errorf("creating storage directory: %w", err) From 697cdacaabac54eac391fca498e5d9206f4f3dd3 Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Sun, 15 Feb 2026 23:38:20 -0500 Subject: [PATCH 6/9] Improve error handling for file closure and clean storage directory path --- github/server/legacy.go | 12 +++++++----- github/server/server.go | 13 +++++++++++-- github/server/start.go | 6 +----- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/github/server/legacy.go b/github/server/legacy.go index 6c8f5bd..6e1fb1b 100644 --- a/github/server/legacy.go +++ b/github/server/legacy.go @@ -117,7 +117,9 @@ func (s *Server) handleLegacyUpload(w http.ResponseWriter, r *http.Request) { f.Seek(start, io.SeekStart) } n, copyErr := io.Copy(f, r.Body) - f.Close() + if err := f.Close(); err != nil && copyErr == nil { + copyErr = err + } if copyErr != nil { http.Error(w, "storage error", http.StatusInternalServerError) return @@ -238,13 +240,13 @@ func (s *Server) handleLegacyListFiles(w http.ResponseWriter, r *http.Request) { // GET /artifact/{path...} func (s *Server) handleLegacyDownload(w http.ResponseWriter, r *http.Request) { fullPath := r.PathValue("path") - idx := strings.IndexByte(fullPath, '/') - if idx < 0 { + before, after, ok := strings.Cut(fullPath, "/") + if !ok { http.Error(w, "invalid path", http.StatusBadRequest) return } - cidStr := fullPath[:idx] - filePath := fullPath[idx+1:] + cidStr := before + filePath := after cid, err := strconv.ParseInt(cidStr, 10, 64) if err != nil { diff --git a/github/server/server.go b/github/server/server.go index fb81121..6b259ca 100644 --- a/github/server/server.go +++ b/github/server/server.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "os" "path/filepath" @@ -586,7 +587,11 @@ func (s *Server) handleBlobUpload(w http.ResponseWriter, r *http.Request) { http.Error(w, "storage error", http.StatusInternalServerError) return } - defer f.Close() + defer func() { + if err := f.Close(); err != nil { + log.Printf("closing blob file: %v", err) + } + }() if _, err := io.Copy(f, r.Body); err != nil { http.Error(w, "storage error", http.StatusInternalServerError) return @@ -604,7 +609,11 @@ func (s *Server) handleBlobUpload(w http.ResponseWriter, r *http.Request) { http.Error(w, "storage error", http.StatusInternalServerError) return } - defer f.Close() + defer func() { + if err := f.Close(); err != nil { + log.Printf("closing blob file: %v", err) + } + }() if _, err := io.Copy(f, r.Body); err != nil { http.Error(w, "storage error", http.StatusInternalServerError) return diff --git a/github/server/start.go b/github/server/start.go index a95e400..08bf8da 100644 --- a/github/server/start.go +++ b/github/server/start.go @@ -52,11 +52,7 @@ func StartServer(cfg Config) (*RunningServer, error) { cfg.OIDCSub = "repo:local/repo:ref:refs/heads/main" } - absDir, err := filepath.Abs(cfg.StorageDir) - if err != nil { - return nil, fmt.Errorf("invalid storage directory: %w", err) - } - cfg.StorageDir = absDir + cfg.StorageDir = filepath.Clean(cfg.StorageDir) if err := os.MkdirAll(cfg.StorageDir, 0o755); err != nil { return nil, fmt.Errorf("creating storage directory: %w", err) From cacc44052081ee22825a4f29c1c0b12d9da0dde3 Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Sun, 15 Feb 2026 23:55:49 -0500 Subject: [PATCH 7/9] Update local GitHub Actions server storage directory creation --- core/graph.go | 6 +++++- github/server/start.go | 4 ---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/core/graph.go b/core/graph.go index 43c2216..c0ce7b3 100644 --- a/core/graph.go +++ b/core/graph.go @@ -474,7 +474,11 @@ func RunGraph(ctx context.Context, graphName string, graphContent []byte, opts R } if opts.LocalGhServer { - storageDir := filepath.Join(finalEnv["RUNNER_TEMP"], "gh-server-storage") + // RUNNER_TEMP is provided by the local editor over a 127.0.0.1-only WebSocket; not an external input. + storageDir, mkErr := os.MkdirTemp(finalEnv["RUNNER_TEMP"], "gh-server-storage-") // lgtm[go/path-injection] + if mkErr != nil { + return CreateErr(nil, mkErr, "failed to create storage directory for local GitHub Actions server") + } rs, srvErr := server.StartServer(server.Config{StorageDir: storageDir}) if srvErr != nil { return CreateErr(nil, srvErr, "failed to start local GitHub Actions server") diff --git a/github/server/start.go b/github/server/start.go index 08bf8da..293a135 100644 --- a/github/server/start.go +++ b/github/server/start.go @@ -54,10 +54,6 @@ func StartServer(cfg Config) (*RunningServer, error) { cfg.StorageDir = filepath.Clean(cfg.StorageDir) - if err := os.MkdirAll(cfg.StorageDir, 0o755); err != nil { - return nil, fmt.Errorf("creating storage directory: %w", err) - } - // Generate random HMAC signing key signingKey := make([]byte, 32) if _, err := rand.Read(signingKey); err != nil { From 7022005fed1239206096e3bea63f247f4159ed87 Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Mon, 16 Feb 2026 00:22:42 -0500 Subject: [PATCH 8/9] Minor fixes --- github/server/cache.go | 69 ++++++++++++++++++++---------------- github/server/legacy.go | 21 ++++++++++- github/server/legacy_test.go | 21 ++++++++--- github/server/server.go | 40 ++++++++++----------- github/server/start.go | 3 +- 5 files changed, 96 insertions(+), 58 deletions(-) diff --git a/github/server/cache.go b/github/server/cache.go index a0093e1..e6534ae 100644 --- a/github/server/cache.go +++ b/github/server/cache.go @@ -211,47 +211,54 @@ func (s *Server) handleGetCacheEntryDownloadURL(w http.ResponseWriter, r *http.R scope = req.Metadata.Scope } - s.mu.RLock() - defer s.mu.RUnlock() + type match struct { + id int64 + key string + } + + var found *match + s.mu.RLock() // 1. Exact match: scope + key + version exactKey := scope + "/" + req.Key + "/" + req.Version if entry, ok := s.caches[exactKey]; ok && entry.Finalized { - downloadURL := s.makeSignedURL("GET", entry.ID) - writeJSON(w, http.StatusOK, GetCacheEntryDownloadURLResponse{ - Ok: true, - SignedDownloadURL: downloadURL, - MatchedKey: entry.Key, - }) - return + found = &match{id: entry.ID, key: entry.Key} } // 2. Prefix match with restore_keys - for _, rk := range req.RestoreKeys { - var best *CacheEntry - for _, entry := range s.caches { - if entry.Scope != scope || entry.Version != req.Version { - continue - } - if !entry.Finalized { - continue - } - if !strings.HasPrefix(entry.Key, rk) { - continue + if found == nil { + for _, rk := range req.RestoreKeys { + var best *CacheEntry + for _, entry := range s.caches { + if entry.Scope != scope || entry.Version != req.Version { + continue + } + if !entry.Finalized { + continue + } + if !strings.HasPrefix(entry.Key, rk) { + continue + } + if best == nil || entry.CreatedAt.After(best.CreatedAt) { + best = entry + } } - if best == nil || entry.CreatedAt.After(best.CreatedAt) { - best = entry + if best != nil { + found = &match{id: best.ID, key: best.Key} + break } } - if best != nil { - downloadURL := s.makeSignedURL("GET", best.ID) - writeJSON(w, http.StatusOK, GetCacheEntryDownloadURLResponse{ - Ok: true, - SignedDownloadURL: downloadURL, - MatchedKey: best.Key, - }) - return - } + } + s.mu.RUnlock() + + if found != nil { + downloadURL := s.makeSignedURL("GET", found.id) + writeJSON(w, http.StatusOK, GetCacheEntryDownloadURLResponse{ + Ok: true, + SignedDownloadURL: downloadURL, + MatchedKey: found.key, + }) + return } writeTwirpError(w, http.StatusNotFound, "not_found", "cache entry not found") diff --git a/github/server/legacy.go b/github/server/legacy.go index 6e1fb1b..ef0da53 100644 --- a/github/server/legacy.go +++ b/github/server/legacy.go @@ -70,6 +70,11 @@ func (s *Server) handleLegacyCreate(w http.ResponseWriter, r *http.Request) { // PUT /v3-upload/{containerId}?itemPath={path} func (s *Server) handleLegacyUpload(w http.ResponseWriter, r *http.Request) { + if !hasBearer(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + cidStr := r.PathValue("containerId") cid, err := strconv.ParseInt(cidStr, 10, 64) if err != nil { @@ -114,7 +119,11 @@ func (s *Server) handleLegacyUpload(w http.ResponseWriter, r *http.Request) { return } if start > 0 { - f.Seek(start, io.SeekStart) + if _, err := f.Seek(start, io.SeekStart); err != nil { + f.Close() + http.Error(w, "storage error", http.StatusInternalServerError) + return + } } n, copyErr := io.Copy(f, r.Body) if err := f.Close(); err != nil && copyErr == nil { @@ -199,6 +208,11 @@ func (s *Server) handleLegacyList(w http.ResponseWriter, r *http.Request) { // GET /download-v3/{containerId}?itemPath={prefix} func (s *Server) handleLegacyListFiles(w http.ResponseWriter, r *http.Request) { + if !hasBearer(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + cidStr := r.PathValue("containerId") cid, err := strconv.ParseInt(cidStr, 10, 64) if err != nil { @@ -239,6 +253,11 @@ func (s *Server) handleLegacyListFiles(w http.ResponseWriter, r *http.Request) { // GET /artifact/{path...} func (s *Server) handleLegacyDownload(w http.ResponseWriter, r *http.Request) { + if !hasBearer(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + fullPath := r.PathValue("path") before, after, ok := strings.Cut(fullPath, "/") if !ok { diff --git a/github/server/legacy_test.go b/github/server/legacy_test.go index 1b7a7eb..049b911 100644 --- a/github/server/legacy_test.go +++ b/github/server/legacy_test.go @@ -60,6 +60,7 @@ func TestLegacyFullCycle(t *testing.T) { // 2. Upload file req, _ := http.NewRequest("PUT", uploadURL+"?itemPath=data/file.txt", bytes.NewReader(fileContent)) req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Authorization", "Bearer test-token") uploadResp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("upload: %v", err) @@ -115,7 +116,9 @@ func TestLegacyFullCycle(t *testing.T) { contentLocation := filesResult.Value[0]["contentLocation"].(string) // 6. Download file - dlResp, err := http.Get(contentLocation) + dlReq, _ := http.NewRequest("GET", contentLocation, nil) + dlReq.Header.Set("Authorization", "Bearer test-token") + dlResp, err := http.DefaultClient.Do(dlReq) if err != nil { t.Fatalf("download: %v", err) } @@ -148,6 +151,7 @@ func TestLegacyChunkedUpload(t *testing.T) { // Upload chunk 1 req, _ := http.NewRequest("PUT", uploadURL+"?itemPath=file.bin", bytes.NewReader(chunk1)) req.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/%d", len(chunk1)-1, total)) + req.Header.Set("Authorization", "Bearer test-token") r, _ := http.DefaultClient.Do(req) r.Body.Close() if r.StatusCode != http.StatusCreated { @@ -157,6 +161,7 @@ func TestLegacyChunkedUpload(t *testing.T) { // Upload chunk 2 req, _ = http.NewRequest("PUT", uploadURL+"?itemPath=file.bin", bytes.NewReader(chunk2)) req.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", len(chunk1), total-1, total)) + req.Header.Set("Authorization", "Bearer test-token") r, _ = http.DefaultClient.Do(req) r.Body.Close() if r.StatusCode != http.StatusCreated { @@ -184,7 +189,9 @@ func TestLegacyChunkedUpload(t *testing.T) { resp.Body.Close() contentLocation := filesResult.Value[0]["contentLocation"].(string) - dlResp, _ := http.Get(contentLocation) + dlReq, _ := http.NewRequest("GET", contentLocation, nil) + dlReq.Header.Set("Authorization", "Bearer test-token") + dlResp, _ := http.DefaultClient.Do(dlReq) defer dlResp.Body.Close() data, _ := io.ReadAll(dlResp.Body) expected := append(chunk1, chunk2...) @@ -215,6 +222,7 @@ func TestLegacyGzipRoundtrip(t *testing.T) { // Upload with Content-Encoding: gzip req, _ := http.NewRequest("PUT", uploadURL+"?itemPath=data.gz", bytes.NewReader(gzipData)) req.Header.Set("Content-Encoding", "gzip") + req.Header.Set("Authorization", "Bearer test-token") r, _ := http.DefaultClient.Do(req) r.Body.Close() if r.StatusCode != http.StatusCreated { @@ -239,7 +247,9 @@ func TestLegacyGzipRoundtrip(t *testing.T) { // Use raw HTTP transport to avoid automatic decompression transport := &http.Transport{DisableCompression: true} client := &http.Client{Transport: transport} - dlResp, _ := client.Get(filesResult.Value[0]["contentLocation"].(string)) + dlReq, _ := http.NewRequest("GET", filesResult.Value[0]["contentLocation"].(string), nil) + dlReq.Header.Set("Authorization", "Bearer test-token") + dlResp, _ := client.Do(dlReq) defer dlResp.Body.Close() if dlResp.Header.Get("Content-Encoding") != "gzip" { @@ -272,6 +282,7 @@ func TestLegacyMultipleFiles(t *testing.T) { for path, content := range files { req, _ := http.NewRequest("PUT", uploadURL+"?itemPath="+path, bytes.NewReader([]byte(content))) + req.Header.Set("Authorization", "Bearer test-token") r, _ := http.DefaultClient.Do(req) r.Body.Close() } @@ -319,14 +330,14 @@ func TestLegacyNotFound(t *testing.T) { } // Download non-existent container - dlResp, _ := http.Get(ts.URL + "/download-v3/9999") + dlResp := legacyRequest(t, ts, "GET", "/download-v3/9999", nil) dlResp.Body.Close() if dlResp.StatusCode != http.StatusNotFound { t.Fatalf("expected 404, got %d", dlResp.StatusCode) } // Download non-existent file - dlResp, _ = http.Get(ts.URL + "/artifact/9999/nofile.txt") + dlResp = legacyRequest(t, ts, "GET", "/artifact/9999/nofile.txt", nil) dlResp.Body.Close() if dlResp.StatusCode != http.StatusNotFound { t.Fatalf("expected 404, got %d", dlResp.StatusCode) diff --git a/github/server/server.go b/github/server/server.go index 6b259ca..1b191b1 100644 --- a/github/server/server.go +++ b/github/server/server.go @@ -271,6 +271,16 @@ func (s *Server) handleCreateArtifact(w http.ResponseWriter, r *http.Request, ru blobPath := s.artifactBlobPath(req.WorkflowRunBackendID, req.Name) + safeDir, err := utils.SafeJoinPath(s.storageDir, filepath.Base(req.WorkflowRunBackendID)) + if err != nil { + writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid artifact path") + return + } + if err := os.MkdirAll(safeDir, 0o755); err != nil { + writeTwirpError(w, http.StatusInternalServerError, "internal", "failed to create storage directory") + return + } + s.mu.Lock() if existing, ok := s.artifacts[key]; ok && existing.Finalized { s.mu.Unlock() @@ -298,16 +308,6 @@ func (s *Server) handleCreateArtifact(w http.ResponseWriter, r *http.Request, ru s.uploadMu[id] = &sync.Mutex{} s.mu.Unlock() - safeDir, err := utils.SafeJoinPath(s.storageDir, filepath.Base(req.WorkflowRunBackendID)) - if err != nil { - writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid artifact path") - return - } - if err := os.MkdirAll(safeDir, 0o755); err != nil { - writeTwirpError(w, http.StatusInternalServerError, "internal", "failed to create storage directory") - return - } - uploadURL := s.makeSignedURL("PUT", id) writeJSON(w, http.StatusOK, CreateArtifactResponse{ @@ -480,6 +480,16 @@ func (s *Server) handleMigrateArtifact(w http.ResponseWriter, r *http.Request, r key := req.WorkflowRunBackendID + "/" + req.Name blobPath := s.artifactBlobPath(req.WorkflowRunBackendID, req.Name) + safeDir, err := utils.SafeJoinPath(s.storageDir, filepath.Base(req.WorkflowRunBackendID)) + if err != nil { + writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid artifact path") + return + } + if err := os.MkdirAll(safeDir, 0o755); err != nil { + writeTwirpError(w, http.StatusInternalServerError, "internal", "failed to create storage directory") + return + } + s.mu.Lock() if existing, ok := s.artifacts[key]; ok && existing.Finalized { s.mu.Unlock() @@ -501,16 +511,6 @@ func (s *Server) handleMigrateArtifact(w http.ResponseWriter, r *http.Request, r s.uploadMu[id] = &sync.Mutex{} s.mu.Unlock() - safeDir, err := utils.SafeJoinPath(s.storageDir, filepath.Base(req.WorkflowRunBackendID)) - if err != nil { - writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid artifact path") - return - } - if err := os.MkdirAll(safeDir, 0o755); err != nil { - writeTwirpError(w, http.StatusInternalServerError, "internal", "failed to create storage directory") - return - } - uploadURL := s.makeSignedURL("PUT", id) writeJSON(w, http.StatusOK, MigrateArtifactResponse{ diff --git a/github/server/start.go b/github/server/start.go index 293a135..a8cdef5 100644 --- a/github/server/start.go +++ b/github/server/start.go @@ -2,6 +2,7 @@ package server import ( "crypto/rand" + "errors" "fmt" "net" "net/http" @@ -85,7 +86,7 @@ func StartServer(cfg Config) (*RunningServer, error) { } go func() { - if err := httpServer.Serve(listener); err != nil { + if err := httpServer.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { fmt.Fprintf(os.Stderr, "server error: %v\n", err) } }() From fd5efce759330b7e9ad40231ade6dfd26739aede Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Mon, 16 Feb 2026 00:26:13 -0500 Subject: [PATCH 9/9] Update e2e tests --- tests_e2e/references/reference_app.sh_l10 | 1 + tests_e2e/references/reference_contexts_env.sh_l26 | 1 + 2 files changed, 2 insertions(+) diff --git a/tests_e2e/references/reference_app.sh_l10 b/tests_e2e/references/reference_app.sh_l10 index d2dcb6c..51b7da7 100644 --- a/tests_e2e/references/reference_app.sh_l10 +++ b/tests_e2e/references/reference_app.sh_l10 @@ -17,6 +17,7 @@ Flags: --env-file string Absolute path to an env file (.env) to load before execution -h, --help help for actrun --local Start a local WebSocket server for direct editor connection + --local-gh-server Start a local server mimicking GitHub Actions artifact, cache, and OIDC services --session-token string The session token from your browser -v, --version version for actrun diff --git a/tests_e2e/references/reference_contexts_env.sh_l26 b/tests_e2e/references/reference_contexts_env.sh_l26 index 954da9f..13b4eed 100644 --- a/tests_e2e/references/reference_contexts_env.sh_l26 +++ b/tests_e2e/references/reference_contexts_env.sh_l26 @@ -17,6 +17,7 @@ Flags: --env-file string Absolute path to an env file (.env) to load before execution -h, --help help for actrun --local Start a local WebSocket server for direct editor connection + --local-gh-server Start a local server mimicking GitHub Actions artifact, cache, and OIDC services --session-token string The session token from your browser -v, --version version for actrun