Skip to content

Commit 39e5275

Browse files
committed
perf(ci): optimize docker sandbox CI from ~10min to ~3min
Three-pronged optimization for the docker-sandbox-online CI job: 1. Multi-stage Dockerfile: split into sandbox-base/test/full targets. Test profiles skip the entire Node.js/pnpm/React UI build stage. Full target (default) retains UI+Node for playground/dev/devcontainer. 2. GHA Docker layer cache: both sandbox jobs now use buildx with cache-from/cache-to (type=gha), eliminating cold image rebuilds. Pre-built images are passed via SANDBOX_IMAGE env var. 3. Clone reference cache: online TestMain pre-clones frequently used repos as bare repos; cloneRepo() uses git --reference to reuse local objects instead of re-downloading from GitHub on every test.
1 parent 1c0528c commit 39e5275

8 files changed

Lines changed: 200 additions & 20 deletions

File tree

.devcontainer/docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ services:
77
build:
88
context: ..
99
dockerfile: docker/sandbox/Dockerfile
10+
target: full
1011
working_dir: /workspace
1112
command: sleep infinity
1213
environment:

.github/workflows/test.yaml

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,24 @@ jobs:
5656
- name: Checkout
5757
uses: actions/checkout@v4
5858

59+
- name: Set up Docker Buildx
60+
uses: docker/setup-buildx-action@v3
61+
62+
- name: Build sandbox image
63+
uses: docker/build-push-action@v6
64+
with:
65+
context: .
66+
file: docker/sandbox/Dockerfile
67+
target: test
68+
load: true
69+
tags: skillshare-sandbox-test:ci
70+
cache-from: type=gha,scope=sandbox-test
71+
cache-to: type=gha,scope=sandbox-test,mode=max
72+
5973
- name: Docker Sandbox Tests (Offline)
60-
run: ./scripts/test_docker.sh
74+
run: ./scripts/test_docker.sh --skip-build
75+
env:
76+
SANDBOX_IMAGE: skillshare-sandbox-test:ci
6177

6278
docker-sandbox-online:
6379
runs-on: ubuntu-latest
@@ -66,8 +82,24 @@ jobs:
6682
- name: Checkout
6783
uses: actions/checkout@v4
6884

85+
- name: Set up Docker Buildx
86+
uses: docker/setup-buildx-action@v3
87+
88+
- name: Build sandbox image
89+
uses: docker/build-push-action@v6
90+
with:
91+
context: .
92+
file: docker/sandbox/Dockerfile
93+
target: test
94+
load: true
95+
tags: skillshare-sandbox-test:ci
96+
cache-from: type=gha,scope=sandbox-test
97+
cache-to: type=gha,scope=sandbox-test,mode=max
98+
6999
- name: Docker Sandbox Tests (Online)
70-
run: ./scripts/test_docker_online.sh
100+
run: ./scripts/test_docker_online.sh --skip-build
101+
env:
102+
SANDBOX_IMAGE: skillshare-sandbox-test:ci
71103

72104
redteam:
73105
runs-on: ubuntu-latest

docker-compose.sandbox.yml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ x-sandbox-common: &sandbox-common
22
build:
33
context: .
44
dockerfile: docker/sandbox/Dockerfile
5+
target: full
56
working_dir: /workspace
67
environment:
78
GOCACHE: /go/build-cache
@@ -17,9 +18,17 @@ x-sandbox-common: &sandbox-common
1718
cpus: "2"
1819
memory: "2G"
1920

21+
x-sandbox-test: &sandbox-test
22+
<<: *sandbox-common
23+
image: ${SANDBOX_IMAGE:-}
24+
build:
25+
context: .
26+
dockerfile: docker/sandbox/Dockerfile
27+
target: test
28+
2029
services:
2130
sandbox-offline:
22-
<<: *sandbox-common
31+
<<: *sandbox-test
2332
profiles:
2433
- offline
2534
command:
@@ -30,7 +39,7 @@ services:
3039
GOCACHE: /go/build-cache
3140

3241
sandbox-online:
33-
<<: *sandbox-common
42+
<<: *sandbox-test
3443
profiles:
3544
- online
3645
command:

docker/sandbox/Dockerfile

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ RUN pnpm install --frozen-lockfile
1313
COPY ui/ ./
1414
RUN pnpm run build
1515

16-
# Stage 2: Go sandbox with pre-built frontend
17-
FROM golang:${GO_VERSION}-bookworm
16+
# Stage 2: Go sandbox base (shared by test and full targets)
17+
FROM golang:${GO_VERSION}-bookworm AS sandbox-base
1818

1919
RUN apt-get update \
2020
&& apt-get install -y --no-install-recommends \
@@ -29,18 +29,6 @@ RUN apt-get update \
2929
RUN go install github.com/air-verse/air@latest \
3030
&& go install github.com/go-delve/delve/cmd/dlv@latest
3131

32-
# Copy node runtime from ui-builder stage (for devcontainer / interactive use)
33-
COPY --from=ui-builder /usr/local/bin/node /usr/local/bin/
34-
COPY --from=ui-builder /usr/local/lib/node_modules /usr/local/lib/node_modules
35-
RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
36-
&& ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \
37-
&& ln -sf ../lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack
38-
ARG PNPM_VERSION
39-
RUN npm install -g pnpm@${PNPM_VERSION}
40-
41-
# Copy pre-built frontend for playground UI cache
42-
COPY --from=ui-builder /ui/dist /ui-dist
43-
4432
COPY docker/sandbox/entrypoint.sh /entrypoint.sh
4533
RUN chmod +x /entrypoint.sh
4634

@@ -52,3 +40,21 @@ RUN mkdir -p /go/build-cache /go/pkg/mod && chmod -R 0777 /go/build-cache /go/pk
5240

5341
ENTRYPOINT ["/entrypoint.sh"]
5442
CMD ["bash", "-c", "./scripts/test.sh"]
43+
44+
# Stage 3: Test target (no UI, no Node.js — for offline/online CI tests)
45+
FROM sandbox-base AS test
46+
47+
# Stage 4: Full target (with UI + Node.js — for playground/dev/devcontainer)
48+
FROM sandbox-base AS full
49+
50+
# Copy node runtime from ui-builder stage (for devcontainer / interactive use)
51+
COPY --from=ui-builder /usr/local/bin/node /usr/local/bin/
52+
COPY --from=ui-builder /usr/local/lib/node_modules /usr/local/lib/node_modules
53+
RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
54+
&& ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \
55+
&& ln -sf ../lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack
56+
ARG PNPM_VERSION
57+
RUN npm install -g pnpm@${PNPM_VERSION}
58+
59+
# Copy pre-built frontend for playground UI cache
60+
COPY --from=ui-builder /ui/dist /ui-dist

internal/install/install_git.go

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,12 @@ func cloneRepo(url, destPath, branch string, shallow bool, onProgress ProgressCa
141141
} else {
142142
args = append(args, "--quiet")
143143
}
144-
if shallow {
144+
// Use a local reference repo when available (speeds up CI by avoiding
145+
// redundant network downloads). --reference is incompatible with
146+
// --depth, so we skip shallow when a reference is found.
147+
if ref := findCloneReference(url); ref != "" {
148+
args = append(args, "--reference", ref)
149+
} else if shallow {
145150
args = append(args, "--depth", "1")
146151
}
147152
if branch != "" {
@@ -151,6 +156,37 @@ func cloneRepo(url, destPath, branch string, shallow bool, onProgress ProgressCa
151156
return runGitCommandWithProgress(args, "", authEnv(url), onProgress)
152157
}
153158

159+
// CloneCacheName converts a clone URL to the bare-repo directory name used
160+
// inside SKILLSHARE_CLONE_CACHE. The format is "owner__repo.git" to avoid
161+
// collisions across owners.
162+
//
163+
// "https://github.com/user/repo.git" → "user__repo.git"
164+
// "https://github.com/user/repo" → "user__repo.git"
165+
// "user/repo" → "user__repo.git"
166+
// "repo" → "repo.git"
167+
func CloneCacheName(cloneURL string) string {
168+
trimmed := strings.TrimSuffix(cloneURL, ".git")
169+
parts := strings.Split(trimmed, "/")
170+
if len(parts) >= 2 {
171+
return parts[len(parts)-2] + "__" + parts[len(parts)-1] + ".git"
172+
}
173+
return filepath.Base(trimmed) + ".git"
174+
}
175+
176+
// findCloneReference checks SKILLSHARE_CLONE_CACHE for a local bare repo
177+
// matching the given URL. Returns the path to the bare repo, or "".
178+
func findCloneReference(cloneURL string) string {
179+
cacheDir := os.Getenv("SKILLSHARE_CLONE_CACHE")
180+
if cacheDir == "" {
181+
return ""
182+
}
183+
ref := filepath.Join(cacheDir, CloneCacheName(cloneURL))
184+
if info, err := os.Stat(ref); err == nil && info.IsDir() {
185+
return ref
186+
}
187+
return ""
188+
}
189+
154190
// gitPull performs a git pull (quiet mode).
155191
// If the remote uses HTTPS and a token is available, it injects
156192
// authentication via GIT_CONFIG env vars (same mechanism as cloneRepo).

internal/install/install_git_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,3 +155,50 @@ func runGit(t *testing.T, dir string, args ...string) {
155155
t.Fatalf("git %v failed: %s\n%s", args, err, out)
156156
}
157157
}
158+
159+
func TestCloneCacheName(t *testing.T) {
160+
tests := []struct {
161+
url string
162+
want string
163+
}{
164+
{"https://github.com/user/repo.git", "user__repo.git"},
165+
{"https://github.com/user/repo", "user__repo.git"},
166+
{"user/repo", "user__repo.git"},
167+
{"https://github.com/runkids/skillshare.git", "runkids__skillshare.git"},
168+
{"https://github.com/org/my-skills", "org__my-skills.git"},
169+
{"repo", "repo.git"},
170+
{"git@github.com:user/repo.git", "git@github.com:user__repo.git"}, // SSH URLs use ":" not "/" — won't match HTTPS cache
171+
}
172+
for _, tt := range tests {
173+
t.Run(tt.url, func(t *testing.T) {
174+
got := CloneCacheName(tt.url)
175+
if got != tt.want {
176+
t.Errorf("CloneCacheName(%q) = %q, want %q", tt.url, got, tt.want)
177+
}
178+
})
179+
}
180+
}
181+
182+
func TestFindCloneReference(t *testing.T) {
183+
// Without SKILLSHARE_CLONE_CACHE, always returns ""
184+
t.Setenv("SKILLSHARE_CLONE_CACHE", "")
185+
if ref := findCloneReference("https://github.com/user/repo.git"); ref != "" {
186+
t.Errorf("expected empty, got %q", ref)
187+
}
188+
189+
// With cache dir containing a matching bare repo
190+
cache := t.TempDir()
191+
bare := filepath.Join(cache, "user__repo.git")
192+
os.MkdirAll(bare, 0755)
193+
t.Setenv("SKILLSHARE_CLONE_CACHE", cache)
194+
195+
ref := findCloneReference("https://github.com/user/repo.git")
196+
if ref != bare {
197+
t.Errorf("expected %q, got %q", bare, ref)
198+
}
199+
200+
// Non-matching URL returns ""
201+
if ref := findCloneReference("https://github.com/other/thing.git"); ref != "" {
202+
t.Errorf("expected empty for non-matching URL, got %q", ref)
203+
}
204+
}

internal/install/sparse_checkout.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,17 @@ func sparseCloneSubdir(url, subdir, destPath, branch string, extraEnv []string,
4949
return fmt.Errorf("sparse checkout requires non-empty subdir")
5050
}
5151

52-
cloneArgs := []string{"clone", "--filter=blob:none", "--no-checkout", "--depth", "1"}
52+
cloneArgs := []string{"clone", "--filter=blob:none", "--no-checkout"}
53+
// Use a local reference repo when available. With --reference the
54+
// checkout step reads blobs from the local alternates instead of
55+
// fetching them from the remote, making the operation nearly instant.
56+
// --reference is incompatible with --depth, so we only add --depth
57+
// when no reference is found.
58+
if ref := findCloneReference(url); ref != "" {
59+
cloneArgs = append(cloneArgs, "--reference", ref)
60+
} else {
61+
cloneArgs = append(cloneArgs, "--depth", "1")
62+
}
5363
if branch != "" {
5464
cloneArgs = append(cloneArgs, "--branch", branch)
5565
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
//go:build online
2+
3+
package integration
4+
5+
import (
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"testing"
10+
11+
"skillshare/internal/install"
12+
)
13+
14+
// TestMain pre-clones frequently used repos as bare repos into a temporary
15+
// cache directory. Online tests that invoke `skillshare install/update`
16+
// benefit from git's --reference mechanism: the CLI reuses local objects
17+
// instead of re-downloading them from GitHub on every test.
18+
func TestMain(m *testing.M) {
19+
cache, err := os.MkdirTemp("", "skillshare-online-cache-*")
20+
if err != nil {
21+
os.Exit(m.Run()) // fall through — tests still work, just slower
22+
}
23+
24+
repos := []string{
25+
"https://github.com/runkids/skillshare.git",
26+
"https://github.com/sickn33/antigravity-awesome-skills.git",
27+
}
28+
for _, url := range repos {
29+
dest := filepath.Join(cache, install.CloneCacheName(url))
30+
cmd := exec.Command("git", "clone", "--bare", url, dest)
31+
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
32+
_ = cmd.Run() // non-fatal: if it fails, tests just skip the cache
33+
}
34+
35+
os.Setenv("SKILLSHARE_CLONE_CACHE", cache)
36+
code := m.Run()
37+
os.RemoveAll(cache)
38+
os.Exit(code)
39+
}

0 commit comments

Comments
 (0)