@@ -6,9 +6,11 @@ import (
66 "errors"
77 "fmt"
88 "io"
9+ "io/fs"
910 "net"
1011 "os"
1112 "os/exec"
13+ "path/filepath"
1214 "regexp"
1315 "strings"
1416 "testing"
@@ -43,6 +45,10 @@ type Runner struct {
4345 // uuid is a unique identifier for this Runner instance.
4446 uuid uuid.UUID
4547
48+ // actionsCachePath is the absolute path where GitHub Actions are cached.
49+ // If empty, a new temporary directory is created for each runner.
50+ actionsCachePath string
51+
4652 // ArtifactsStorage is the storage for artifacts uploaded during the workflow run.
4753 ArtifactsStorage ArtifactsStorage
4854
@@ -65,6 +71,13 @@ type Runner struct {
6571 ContainerArchitecture string
6672}
6773
74+ // actionsCachePathBase is the base path for the action cache.
75+ var actionsCachePathBase = filepath .Join ("/tmp" , "act-actions-cache" )
76+
77+ // TemplateActionsCachePath is the path where GitHub Actions are pre-cached during the warmup workflow.
78+ // This cache is then copied to each runner's actions cache path for the actual workflow runs.
79+ var TemplateActionsCachePath = filepath .Join (actionsCachePathBase , "template" )
80+
6881// RunnerOption is a function that configures a Runner.
6982type RunnerOption func (r * Runner )
7083
@@ -88,6 +101,13 @@ func WithLinuxAMD64ContainerArchitecture() RunnerOption {
88101 return WithContainerArchitecture ("linux/amd64" )
89102}
90103
104+ // WithActionsCachePath sets the actions cache path for the runner (absolute path).
105+ func WithActionsCachePath (cachePath string ) RunnerOption {
106+ return func (r * Runner ) {
107+ r .actionsCachePath = cachePath
108+ }
109+ }
110+
91111// NewRunner creates a new Runner instance.
92112func NewRunner (t * testing.T , opts ... RunnerOption ) (* Runner , error ) {
93113 // Get GitHub token from environment (GHA) or gh CLI (local)
@@ -119,7 +139,10 @@ func NewRunner(t *testing.T, opts ...RunnerOption) (*Runner, error) {
119139 for _ , opt := range opts {
120140 opt (r )
121141 }
122-
142+ // Default to a new temporary directory for the actions cache if not set.
143+ if r .actionsCachePath == "" {
144+ WithActionsCachePath (filepath .Join (actionsCachePathBase , r .uuid .String ()))(r )
145+ }
123146 return r , nil
124147}
125148
@@ -143,9 +166,6 @@ func (r *Runner) args(eventKind EventKind, actor string, workflowFile string, pa
143166 // Unique artifact server port and path per act runner instance
144167 fmt .Sprintf ("--artifact-server-port=%d" , artifactServerPort ),
145168 "--artifact-server-path=/tmp/act-artifacts/" + r .uuid .String () + "/" ,
146- // Unique action cache path per act runner instance to prevent cache corruption
147- // when running multiple tests in parallel or reusing runners with stale cache
148- "--action-cache-path=/tmp/act-action-cache/" + r .uuid .String () + "/" ,
149169
150170 // Required for cloning private repos
151171 "--secret" , "GITHUB_TOKEN=" + r .gitHubToken ,
@@ -156,6 +176,19 @@ func (r *Runner) args(eventKind EventKind, actor string, workflowFile string, pa
156176 // - /tmp: for temporary files, so the host's /tmp is used
157177 "--container-options" , `"-v $PWD/tests/act/mockdata:/mockdata -v ` + r .GCS .basePath + `:/gcs -v /tmp:/tmp"` ,
158178 }
179+ if r .actionsCachePath != "" {
180+ // Create and use per-runner cache.
181+ // Do not pre-populate the cache if we are using the shared cache (cache warmup).
182+ if r .actionsCachePath != TemplateActionsCachePath {
183+ if err := copyDir (TemplateActionsCachePath , r .actionsCachePath ); err != nil {
184+ return nil , fmt .Errorf ("copy action cache: %w" , err )
185+ }
186+ }
187+ args = append (args , "--action-cache-path" , r .actionsCachePath )
188+ } else {
189+ // Use shared cache
190+ args = append (args , "--action-cache-path" , TemplateActionsCachePath )
191+ }
159192
160193 // Map local all possible references of plugin-ci-workflows to the local repository
161194 localRepoArgs , err := r .localRepositoryArgs ()
@@ -288,7 +321,10 @@ func (r *Runner) Run(workflow workflow.Workflow, event Event) (runResult *RunRes
288321 return nil , fmt .Errorf ("start act: %w" , err )
289322 }
290323
291- // Process json logs in stdout stream
324+ // Process json logs in stdout stream.
325+ // This must complete BEFORE cmd.Wait() is called, because cmd.Wait()
326+ // closes the stdout pipe. If the goroutine is still reading when the
327+ // pipe is closed, it will get a "file already closed" error.
292328 errs := make (chan error , 1 )
293329 go func () {
294330 if err := r .processStream (stdout , runResult ); err != nil {
@@ -297,7 +333,15 @@ func (r *Runner) Run(workflow workflow.Workflow, event Event) (runResult *RunRes
297333 errs <- nil
298334 }()
299335
300- // Wait for act to finish
336+ // Wait for stdout processing to complete FIRST.
337+ // The scanner will return EOF when the process exits and closes its stdout.
338+ if streamErr := <- errs ; streamErr != nil {
339+ // Still call Wait to clean up the process
340+ _ = cmd .Wait ()
341+ return nil , streamErr
342+ }
343+
344+ // Now wait for the process to fully exit and get its exit status.
301345 if err := cmd .Wait (); err != nil {
302346 if exitErr , ok := err .(* exec.ExitError ); ok && exitErr .ExitCode () != 0 {
303347 runResult .Success = false
@@ -307,8 +351,7 @@ func (r *Runner) Run(workflow workflow.Workflow, event Event) (runResult *RunRes
307351 }
308352 runResult .Success = true
309353
310- // Wait for stdout processing to complete
311- return runResult , <- errs
354+ return runResult , nil
312355}
313356
314357// processStream processes the given reader line by line as JSON log lines generated by act.
@@ -491,3 +534,84 @@ func getFreePort() (port int, err error) {
491534 }
492535 return
493536}
537+
538+ // copyDir recursively copies a directory tree from src to dst.
539+ // If src does not exist, it creates an empty dst directory.
540+ func copyDir (src , dst string ) error {
541+ // Check if source exists
542+ srcInfo , err := os .Stat (src )
543+ if os .IsNotExist (err ) {
544+ // Source doesn't exist, just create an empty destination directory
545+ return os .MkdirAll (dst , 0755 )
546+ }
547+ if err != nil {
548+ return fmt .Errorf ("stat source: %w" , err )
549+ }
550+ if ! srcInfo .IsDir () {
551+ return fmt .Errorf ("source is not a directory: %s" , src )
552+ }
553+
554+ // Create destination directory
555+ if err := os .MkdirAll (dst , srcInfo .Mode ()); err != nil {
556+ return fmt .Errorf ("create destination directory: %w" , err )
557+ }
558+
559+ // Walk through source directory and copy all files
560+ return filepath .WalkDir (src , func (path string , d fs.DirEntry , err error ) error {
561+ if err != nil {
562+ return err
563+ }
564+
565+ // Calculate relative path and destination path
566+ relPath , err := filepath .Rel (src , path )
567+ if err != nil {
568+ return fmt .Errorf ("get relative path: %w" , err )
569+ }
570+ dstPath := filepath .Join (dst , relPath )
571+
572+ if d .IsDir () {
573+ info , err := d .Info ()
574+ if err != nil {
575+ return fmt .Errorf ("get dir info: %w" , err )
576+ }
577+ return os .MkdirAll (dstPath , info .Mode ())
578+ }
579+
580+ // Copy file
581+ return copyFile (path , dstPath )
582+ })
583+ }
584+
585+ // copyFile copies a single file from src to dst, preserving permissions.
586+ func copyFile (src , dst string ) (err error ) {
587+ srcFile , err := os .Open (src )
588+ if err != nil {
589+ return fmt .Errorf ("open source file: %w" , err )
590+ }
591+ defer func () {
592+ if closeErr := srcFile .Close (); closeErr != nil && err == nil {
593+ err = closeErr
594+ }
595+ }()
596+
597+ srcInfo , err := srcFile .Stat ()
598+ if err != nil {
599+ return fmt .Errorf ("stat source file: %w" , err )
600+ }
601+
602+ dstFile , err := os .OpenFile (dst , os .O_WRONLY | os .O_CREATE | os .O_TRUNC , srcInfo .Mode ())
603+ if err != nil {
604+ return fmt .Errorf ("create destination file: %w" , err )
605+ }
606+ defer func () {
607+ if closeErr := dstFile .Close (); closeErr != nil && err == nil {
608+ err = closeErr
609+ }
610+ }()
611+
612+ if _ , err := io .Copy (dstFile , srcFile ); err != nil {
613+ return fmt .Errorf ("copy file contents: %w" , err )
614+ }
615+
616+ return nil
617+ }
0 commit comments