Skip to content

Commit f601916

Browse files
authored
perf(ci): cache used actions as part of act cache warmup (#495)
1 parent 4e9ca04 commit f601916

4 files changed

Lines changed: 324 additions & 45 deletions

File tree

Makefile

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
.PHONY: reset-mockdata clean-node-modules clean-act-tmp clean-lfs mockdata-dist mockdata-dist-artifacts mockdata
1+
.PHONY: clean-node-modules clean-dist clean-act-tmp clean-act-toolcache-volumes clean-lfs clean \
2+
reset-mockdata mockdata-dist mockdata-dist-artifacts mockdata \
3+
genreadme act-lint act-test actionlint
24

35
clean-node-modules:
46
find tests -name node_modules -type d -prune -exec rm -rf '{}' +
@@ -8,7 +10,7 @@ clean-dist:
810

911
clean-act-tmp:
1012
rm -rf /tmp/act-artifacts
11-
rm -rf /tmp/act-action-cache
13+
rm -rf /tmp/act-actions-cache
1214
rm -rf /tmp/act-cache
1315
rm -rf /tmp/act-gcs
1416

tests/act/internal/act/act.go

Lines changed: 132 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
6982
type 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.
92112
func 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+
}

tests/act/internal/act/actions.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package act
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"regexp"
7+
"strings"
8+
)
9+
10+
// actionUsesRegex matches "uses: <action>@<ref>" patterns in YAML files.
11+
// It captures the full action reference including the version/commit.
12+
var actionUsesRegex = regexp.MustCompile(`uses:\s*["']?([^@\s"'#]+@[^\s"'#]+)`)
13+
14+
// ExtractExternalActions scans YAML files in the given directories and returns
15+
// unique external action references (uses: owner/repo@ref).
16+
// It filters out internal grafana/plugin-ci-workflows references.
17+
// The returned slice contains deduplicated action references sorted alphabetically.
18+
func ExtractExternalActions(dirs ...string) ([]string, error) {
19+
seen := make(map[string]struct{})
20+
var actions []string
21+
22+
for _, dir := range dirs {
23+
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
24+
if err != nil {
25+
return err
26+
}
27+
if info.IsDir() {
28+
return nil
29+
}
30+
// Only process YAML files
31+
ext := filepath.Ext(path)
32+
if ext != ".yml" && ext != ".yaml" {
33+
return nil
34+
}
35+
36+
content, err := os.ReadFile(path)
37+
if err != nil {
38+
return err
39+
}
40+
41+
matches := actionUsesRegex.FindAllSubmatch(content, -1)
42+
for _, match := range matches {
43+
if len(match) < 2 {
44+
continue
45+
}
46+
action := string(match[1])
47+
48+
// Skip internal references
49+
if strings.HasPrefix(action, "grafana/plugin-ci-workflows") {
50+
continue
51+
}
52+
53+
// Skip if already seen
54+
if _, ok := seen[action]; ok {
55+
continue
56+
}
57+
seen[action] = struct{}{}
58+
actions = append(actions, action)
59+
}
60+
return nil
61+
})
62+
if err != nil {
63+
return nil, err
64+
}
65+
}
66+
67+
return actions, nil
68+
}
69+

0 commit comments

Comments
 (0)