Skip to content

Commit d33e402

Browse files
committed
First gh mock server for github actions
1 parent 818bce1 commit d33e402

12 files changed

Lines changed: 3114 additions & 0 deletions

File tree

cmd/cmd_root.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,15 @@ var (
3131
flagEnvFile string
3232
flagCreateDebugSession bool
3333
flagLocal bool
34+
flagLocalGhServer bool
3435

3536
finalConfigFile string
3637
finalConcurrency string
3738
finalSessionToken string
3839
finalConfigValueSource string
3940
finalCreateDebugSession bool
4041
finalLocal bool
42+
finalLocalGhServer bool
4143

4244
finalGraphFile string
4345
finalGraphArgs []string
@@ -112,6 +114,7 @@ var cmdRoot = &cobra.Command{
112114
finalCreateDebugSession = finalCreateDebugSessionStr == "true" || finalCreateDebugSessionStr == "1"
113115

114116
finalLocal = flagLocal
117+
finalLocalGhServer = flagLocalGhServer
115118

116119
// the block below is used to distinguish between implicit graph files (eg if defined in an env var) + graph flags
117120
// vs explicit graph file (eg provided by positional arg) + graph flags.
@@ -201,6 +204,7 @@ func cmdRootRun(cmd *cobra.Command, args []string) {
201204
OverrideSecrets: nil,
202205
OverrideInputs: nil,
203206
Args: finalGraphArgs,
207+
LocalGhServer: finalLocalGhServer,
204208
}
205209

206210
if core.IsSharedGraphURL(finalGraphFile) {
@@ -253,6 +257,7 @@ func init() {
253257
cmdRoot.Flags().StringVar(&flagSessionToken, "session-token", "", "The session token from your browser")
254258
cmdRoot.Flags().BoolVar(&flagCreateDebugSession, "create-debug-session", false, "Create a debug session by connecting to the web app")
255259
cmdRoot.Flags().BoolVar(&flagLocal, "local", false, "Start a local WebSocket server for direct editor connection")
260+
cmdRoot.Flags().BoolVar(&flagLocalGhServer, "local-gh-server", false, "Start a local server mimicking GitHub Actions artifact, cache, and OIDC services")
256261

257262
// disable interspersed flag parsing to allow passing arbitrary flags to graphs.
258263
// it stops cobra from parsing flags once it hits positional argument

core/graph.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"sync"
1717
"time"
1818

19+
"github.com/actionforge/actrun-cli/github/server"
1920
"github.com/actionforge/actrun-cli/utils"
2021
"github.com/google/uuid"
2122

@@ -30,6 +31,7 @@ type RunOpts struct {
3031
OverrideInputs map[string]any
3132
OverrideEnv map[string]string
3233
Args []string
34+
LocalGhServer bool
3335
}
3436

3537
type ActionGraph struct {
@@ -471,6 +473,17 @@ func RunGraph(ctx context.Context, graphName string, graphContent []byte, opts R
471473
return CreateErr(nil, err, "failed to setup GitHub Actions environment")
472474
}
473475

476+
if opts.LocalGhServer {
477+
storageDir := filepath.Join(finalEnv["RUNNER_TEMP"], "gh-server-storage")
478+
rs, srvErr := server.StartServer(server.Config{StorageDir: storageDir})
479+
if srvErr != nil {
480+
return CreateErr(nil, srvErr, "failed to start local GitHub Actions server")
481+
}
482+
defer rs.Stop()
483+
rs.InjectEnv(finalEnv)
484+
utils.LogOut.Infof("local GitHub Actions server started at %s\n", rs.URL)
485+
}
486+
474487
// Use the updated GITHUB_WORKSPACE as the working directory.
475488
// SetupGitHubActionsEnv replaces GITHUB_WORKSPACE with a fresh temp folder.
476489
if cwd, ok := finalEnv["GITHUB_WORKSPACE"]; ok {

github/server/cache.go

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
package server
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"os"
8+
"path/filepath"
9+
"strconv"
10+
"strings"
11+
"sync"
12+
"time"
13+
)
14+
15+
type CacheEntry struct {
16+
ID int64
17+
Key string
18+
Version string
19+
Scope string
20+
Size int64
21+
Finalized bool
22+
CreatedAt time.Time
23+
}
24+
25+
// --- Twirp dispatcher ---
26+
27+
func (s *Server) handleCacheTwirp(w http.ResponseWriter, r *http.Request) {
28+
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
29+
writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "Content-Type must be application/json")
30+
return
31+
}
32+
33+
if _, _, err := parseJWT(r.Header.Get("Authorization")); err != nil {
34+
writeTwirpError(w, http.StatusUnauthorized, "unauthenticated", err.Error())
35+
return
36+
}
37+
38+
method := r.PathValue("method")
39+
switch method {
40+
case "CreateCacheEntry":
41+
s.handleCreateCacheEntry(w, r)
42+
case "FinalizeCacheEntryUpload":
43+
s.handleFinalizeCacheEntry(w, r)
44+
case "GetCacheEntryDownloadURL":
45+
s.handleGetCacheEntryDownloadURL(w, r)
46+
case "DeleteCacheEntry":
47+
s.handleDeleteCacheEntry(w, r)
48+
default:
49+
writeTwirpError(w, http.StatusNotFound, "not_found", fmt.Sprintf("unknown method: %s", method))
50+
}
51+
}
52+
53+
// --- Request/Response types ---
54+
55+
type CacheMetadata struct {
56+
RepositoryID string `json:"repository_id"`
57+
Scope string `json:"scope"`
58+
}
59+
60+
type CreateCacheEntryRequest struct {
61+
Key string `json:"key"`
62+
Version string `json:"version"`
63+
Metadata *CacheMetadata `json:"metadata,omitempty"`
64+
}
65+
66+
type CreateCacheEntryResponse struct {
67+
Ok bool `json:"ok"`
68+
SignedUploadURL string `json:"signed_upload_url"`
69+
}
70+
71+
type FinalizeCacheEntryRequest struct {
72+
Key string `json:"key"`
73+
Version string `json:"version"`
74+
SizeBytes int64 `json:"size_bytes"`
75+
}
76+
77+
type FinalizeCacheEntryResponse struct {
78+
Ok bool `json:"ok"`
79+
EntryID string `json:"entry_id"`
80+
}
81+
82+
type GetCacheEntryDownloadURLRequest struct {
83+
Metadata *CacheMetadata `json:"metadata,omitempty"`
84+
Key string `json:"key"`
85+
RestoreKeys []string `json:"restore_keys,omitempty"`
86+
Version string `json:"version"`
87+
}
88+
89+
type GetCacheEntryDownloadURLResponse struct {
90+
Ok bool `json:"ok"`
91+
SignedDownloadURL string `json:"signed_download_url"`
92+
}
93+
94+
type DeleteCacheEntryRequest struct {
95+
Key string `json:"key"`
96+
Version string `json:"version"`
97+
}
98+
99+
type DeleteCacheEntryResponse struct {
100+
Ok bool `json:"ok"`
101+
}
102+
103+
// --- RPC handlers ---
104+
105+
func (s *Server) handleCreateCacheEntry(w http.ResponseWriter, r *http.Request) {
106+
var req CreateCacheEntryRequest
107+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
108+
writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid JSON")
109+
return
110+
}
111+
if req.Key == "" || req.Version == "" {
112+
writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "key and version are required")
113+
return
114+
}
115+
116+
scope := ""
117+
if req.Metadata != nil {
118+
scope = req.Metadata.Scope
119+
}
120+
cacheKey := scope + "/" + req.Key + "/" + req.Version
121+
122+
s.mu.Lock()
123+
// If entry already exists, overwrite (caches are mutable)
124+
if existing, ok := s.caches[cacheKey]; ok {
125+
delete(s.cacheByID, existing.ID)
126+
delete(s.uploadMu, existing.ID)
127+
}
128+
id := s.nextID
129+
s.nextID++
130+
entry := &CacheEntry{
131+
ID: id,
132+
Key: req.Key,
133+
Version: req.Version,
134+
Scope: scope,
135+
CreatedAt: time.Now(),
136+
}
137+
s.caches[cacheKey] = entry
138+
s.cacheByID[id] = entry
139+
s.uploadMu[id] = &sync.Mutex{}
140+
s.mu.Unlock()
141+
142+
uploadURL := s.makeSignedURL("PUT", id)
143+
writeJSON(w, http.StatusOK, CreateCacheEntryResponse{
144+
Ok: true,
145+
SignedUploadURL: uploadURL,
146+
})
147+
}
148+
149+
func (s *Server) handleFinalizeCacheEntry(w http.ResponseWriter, r *http.Request) {
150+
var req FinalizeCacheEntryRequest
151+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
152+
writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid JSON")
153+
return
154+
}
155+
156+
s.mu.Lock()
157+
var found *CacheEntry
158+
for _, entry := range s.caches {
159+
if entry.Key == req.Key && entry.Version == req.Version {
160+
found = entry
161+
break
162+
}
163+
}
164+
if found == nil {
165+
s.mu.Unlock()
166+
writeTwirpError(w, http.StatusNotFound, "not_found", "cache entry not found")
167+
return
168+
}
169+
found.Size = req.SizeBytes
170+
found.Finalized = true
171+
s.mu.Unlock()
172+
173+
writeJSON(w, http.StatusOK, FinalizeCacheEntryResponse{
174+
Ok: true,
175+
EntryID: strconv.FormatInt(found.ID, 10),
176+
})
177+
}
178+
179+
func (s *Server) handleGetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Request) {
180+
var req GetCacheEntryDownloadURLRequest
181+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
182+
writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid JSON")
183+
return
184+
}
185+
186+
scope := ""
187+
if req.Metadata != nil {
188+
scope = req.Metadata.Scope
189+
}
190+
191+
s.mu.RLock()
192+
defer s.mu.RUnlock()
193+
194+
// 1. Exact match: scope + key + version
195+
exactKey := scope + "/" + req.Key + "/" + req.Version
196+
if entry, ok := s.caches[exactKey]; ok && entry.Finalized {
197+
downloadURL := s.makeSignedURL("GET", entry.ID)
198+
writeJSON(w, http.StatusOK, GetCacheEntryDownloadURLResponse{
199+
Ok: true,
200+
SignedDownloadURL: downloadURL,
201+
})
202+
return
203+
}
204+
205+
// 2. Prefix match with restore_keys
206+
for _, rk := range req.RestoreKeys {
207+
var best *CacheEntry
208+
for _, entry := range s.caches {
209+
if entry.Scope != scope || entry.Version != req.Version {
210+
continue
211+
}
212+
if !entry.Finalized {
213+
continue
214+
}
215+
if !strings.HasPrefix(entry.Key, rk) {
216+
continue
217+
}
218+
if best == nil || entry.CreatedAt.After(best.CreatedAt) {
219+
best = entry
220+
}
221+
}
222+
if best != nil {
223+
downloadURL := s.makeSignedURL("GET", best.ID)
224+
writeJSON(w, http.StatusOK, GetCacheEntryDownloadURLResponse{
225+
Ok: true,
226+
SignedDownloadURL: downloadURL,
227+
})
228+
return
229+
}
230+
}
231+
232+
writeTwirpError(w, http.StatusNotFound, "not_found", "cache entry not found")
233+
}
234+
235+
func (s *Server) handleDeleteCacheEntry(w http.ResponseWriter, r *http.Request) {
236+
var req DeleteCacheEntryRequest
237+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
238+
writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid JSON")
239+
return
240+
}
241+
242+
s.mu.Lock()
243+
var found *CacheEntry
244+
var foundKey string
245+
for k, entry := range s.caches {
246+
if entry.Key == req.Key && entry.Version == req.Version {
247+
found = entry
248+
foundKey = k
249+
break
250+
}
251+
}
252+
if found == nil {
253+
s.mu.Unlock()
254+
writeTwirpError(w, http.StatusNotFound, "not_found", "cache entry not found")
255+
return
256+
}
257+
delete(s.caches, foundKey)
258+
delete(s.cacheByID, found.ID)
259+
delete(s.uploadMu, found.ID)
260+
s.mu.Unlock()
261+
262+
blobPath := filepath.Join(s.storageDir, fmt.Sprintf("%d.blob", found.ID))
263+
os.Remove(blobPath)
264+
265+
writeJSON(w, http.StatusOK, DeleteCacheEntryResponse{Ok: true})
266+
}

0 commit comments

Comments
 (0)