diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index df89780b2..e7c4c1e3d 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -9,6 +9,9 @@ on: push: branches: [ main ] +env: + KIND_VERSION: "v0.27.0" + concurrency: group: e2e-tests-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: true @@ -87,71 +90,102 @@ jobs: with: driver-opts: network=host - - name: Build component images from PR code + - name: Build or pull frontend image + if: needs.detect-changes.outputs.frontend == 'true' + uses: docker/build-push-action@v7 + with: + context: components/frontend + file: components/frontend/Dockerfile + load: true + tags: quay.io/ambient_code/vteam_frontend:e2e-test + cache-from: | + type=gha,scope=frontend-amd64 + type=gha,scope=e2e-frontend + cache-to: type=gha,mode=max,scope=e2e-frontend + + - name: Pull frontend latest (unchanged) + if: needs.detect-changes.outputs.frontend != 'true' run: | - echo "======================================" - echo "Building images from PR code..." - echo "PR #${{ github.event.pull_request.number }}" - echo "SHA: ${{ github.event.pull_request.head.sha }}" - echo "======================================" + docker pull quay.io/ambient_code/vteam_frontend:latest + docker tag quay.io/ambient_code/vteam_frontend:latest quay.io/ambient_code/vteam_frontend:e2e-test - # Build frontend image (if changed or use latest) - if [ "${{ needs.detect-changes.outputs.frontend }}" == "true" ]; then - echo "Building frontend (changed)..." - docker build -t quay.io/ambient_code/vteam_frontend:e2e-test \ - -f components/frontend/Dockerfile \ - components/frontend - else - echo "Frontend unchanged, pulling latest..." - docker pull quay.io/ambient_code/vteam_frontend:latest - docker tag quay.io/ambient_code/vteam_frontend:latest quay.io/ambient_code/vteam_frontend:e2e-test - fi + - name: Build or pull backend image + if: needs.detect-changes.outputs.backend == 'true' + uses: docker/build-push-action@v7 + with: + context: components/backend + file: components/backend/Dockerfile + load: true + tags: quay.io/ambient_code/vteam_backend:e2e-test + cache-from: | + type=gha,scope=backend-amd64 + type=gha,scope=e2e-backend + cache-to: type=gha,mode=max,scope=e2e-backend - # Build backend image (if changed or use latest) - if [ "${{ needs.detect-changes.outputs.backend }}" == "true" ]; then - echo "Building backend (changed)..." - docker build -t quay.io/ambient_code/vteam_backend:e2e-test \ - -f components/backend/Dockerfile \ - components/backend - else - echo "Backend unchanged, pulling latest..." - docker pull quay.io/ambient_code/vteam_backend:latest - docker tag quay.io/ambient_code/vteam_backend:latest quay.io/ambient_code/vteam_backend:e2e-test - fi + - name: Pull backend latest (unchanged) + if: needs.detect-changes.outputs.backend != 'true' + run: | + docker pull quay.io/ambient_code/vteam_backend:latest + docker tag quay.io/ambient_code/vteam_backend:latest quay.io/ambient_code/vteam_backend:e2e-test - # Build operator image (if changed or use latest) - if [ "${{ needs.detect-changes.outputs.operator }}" == "true" ]; then - echo "Building operator (changed)..." - docker build -t quay.io/ambient_code/vteam_operator:e2e-test \ - -f components/operator/Dockerfile \ - components/operator - else - echo "Operator unchanged, pulling latest..." - docker pull quay.io/ambient_code/vteam_operator:latest - docker tag quay.io/ambient_code/vteam_operator:latest quay.io/ambient_code/vteam_operator:e2e-test - fi + - name: Build or pull operator image + if: needs.detect-changes.outputs.operator == 'true' + uses: docker/build-push-action@v7 + with: + context: components/operator + file: components/operator/Dockerfile + load: true + tags: quay.io/ambient_code/vteam_operator:e2e-test + cache-from: | + type=gha,scope=operator-amd64 + type=gha,scope=e2e-operator + cache-to: type=gha,mode=max,scope=e2e-operator - # Build ambient-runner image (if changed or use latest) - if [ "${{ needs.detect-changes.outputs.claude-runner }}" == "true" ]; then - echo "Building ambient-runner (changed)..." - docker build -t quay.io/ambient_code/vteam_claude_runner:e2e-test \ - -f components/runners/ambient-runner/Dockerfile \ - components/runners - else - echo "Claude-runner unchanged, pulling latest..." - docker pull quay.io/ambient_code/vteam_claude_runner:latest - docker tag quay.io/ambient_code/vteam_claude_runner:latest quay.io/ambient_code/vteam_claude_runner:e2e-test - fi + - name: Pull operator latest (unchanged) + if: needs.detect-changes.outputs.operator != 'true' + run: | + docker pull quay.io/ambient_code/vteam_operator:latest + docker tag quay.io/ambient_code/vteam_operator:latest quay.io/ambient_code/vteam_operator:e2e-test - echo "" - echo "✅ All images ready" - docker images | grep e2e-test + - name: Build or pull ambient-runner image + if: needs.detect-changes.outputs.claude-runner == 'true' + uses: docker/build-push-action@v7 + with: + context: components/runners + file: components/runners/ambient-runner/Dockerfile + load: true + tags: quay.io/ambient_code/vteam_claude_runner:e2e-test + cache-from: | + type=gha,scope=ambient-runner-amd64 + type=gha,scope=e2e-ambient-runner + cache-to: type=gha,mode=max,scope=e2e-ambient-runner + + - name: Pull ambient-runner latest (unchanged) + if: needs.detect-changes.outputs.claude-runner != 'true' + run: | + docker pull quay.io/ambient_code/vteam_claude_runner:latest + docker tag quay.io/ambient_code/vteam_claude_runner:latest quay.io/ambient_code/vteam_claude_runner:e2e-test + + - name: Show built images + run: docker images | grep e2e-test + + - name: Cache kind binary + uses: actions/cache@v4 + with: + path: ~/k8s-tools/kind + key: kind-${{ runner.os }}-${{ env.KIND_VERSION }} - name: Install kind run: | - curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.27.0/kind-linux-amd64 - chmod +x ./kind - sudo mv ./kind /usr/local/bin/kind + mkdir -p ~/k8s-tools + if [[ ! -f ~/k8s-tools/kind ]]; then + echo "Downloading kind $KIND_VERSION..." + curl -sLo ~/k8s-tools/kind "https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-amd64" + chmod +x ~/k8s-tools/kind + else + echo "Using cached kind" + fi + sudo cp ~/k8s-tools/kind /usr/local/bin/kind kind version - name: Setup kind cluster diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 338b56169..713c21f1a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -141,14 +141,7 @@ jobs: cd components/backend go vet ./... - - name: Run golangci-lint - uses: golangci/golangci-lint-action@v9 - with: - version: latest - working-directory: components/backend - args: --timeout=5m - - - name: Run golangci-lint (test build tags) + - name: Run golangci-lint (all build tags) uses: golangci/golangci-lint-action@v9 with: version: latest diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 12b013e6c..fc08ca0c8 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -128,12 +128,11 @@ jobs: go run github.com/onsi/ginkgo/v2/ginkgo -r -v --cover --keep-going --github-output=true --tags=test --label-filter=${{ steps.configure.outputs.TEST_LABEL }} --junit-report=${{ env.JUNIT_FILENAME }} --output-dir=reports -- -testNamespace=${{ steps.configure.outputs.DEFAULT_NAMESPACE }} continue-on-error: true - - name: Install Junit2Html plugin and generate report + - name: Generate HTML test report if: (!cancelled()) shell: bash run: | - pip install junit2html - junit2html ${{ env.TESTS_DIR }}/reports/${{ env.JUNIT_FILENAME }} ${{ env.TESTS_DIR }}/reports/test-report.html + pipx run junit2html ${{ env.TESTS_DIR }}/reports/${{ env.JUNIT_FILENAME }} ${{ env.TESTS_DIR }}/reports/test-report.html continue-on-error: true - name: Configure report name diff --git a/docs/superpowers/plans/2026-04-11-ci-improvements.md b/docs/superpowers/plans/2026-04-11-ci-improvements.md new file mode 100644 index 000000000..ca6830f92 --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-ci-improvements.md @@ -0,0 +1,313 @@ +# CI Improvements Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reduce PR feedback loop wall-clock time from ~10.4m P50 to ~5-7m through targeted caching and shared Docker layer reuse. + +**Architecture:** Replace plain `docker build` in E2E with BuildKit builds that read from the same GHA cache scopes written by components-build-deploy. Add kind binary caching. Consolidate redundant golangci-lint passes. Replace pip-installed junit2html with pipx. + +**Tech Stack:** GitHub Actions, Docker BuildKit, GHA cache, golangci-lint, pipx + +--- + +### Task 1: Add Docker BuildKit Layer Caching to E2E Image Builds + +**Files:** +- Modify: `.github/workflows/e2e.yml:86-148` + +This is the highest-impact change. The E2E workflow currently uses plain `docker build` for 4 component images with no layer caching. We replace the monolithic shell script with individual `docker/build-push-action@v7` steps that use `cache-from` to read layers from both the components-build-deploy workflow's cache and the E2E's own cache. + +The components-build-deploy workflow writes cache with scopes like `frontend-amd64`, `backend-amd64`, etc. E2E runs on `ubuntu-latest` (amd64), so it can read those layers directly. + +- [ ] **Step 1: Replace the monolithic build step with individual buildx build steps** + +Replace the single "Build component images from PR code" step (lines 90-148) with 4 individual conditional steps. Each uses `docker/build-push-action@v7` with `load: true` (loads into local Docker daemon instead of pushing to registry) and reads from the components-build cache scope. + +Replace this block (lines 90-148): + +```yaml + - name: Build component images from PR code + run: | + echo "======================================" + ...entire shell script... +``` + +With these 4 steps: + +```yaml + - name: Build or pull frontend image + if: needs.detect-changes.outputs.frontend == 'true' + uses: docker/build-push-action@v7 + with: + context: components/frontend + file: components/frontend/Dockerfile + load: true + tags: quay.io/ambient_code/vteam_frontend:e2e-test + cache-from: | + type=gha,scope=frontend-amd64 + type=gha,scope=e2e-frontend + cache-to: type=gha,mode=max,scope=e2e-frontend + + - name: Pull frontend latest (unchanged) + if: needs.detect-changes.outputs.frontend != 'true' + run: | + docker pull quay.io/ambient_code/vteam_frontend:latest + docker tag quay.io/ambient_code/vteam_frontend:latest quay.io/ambient_code/vteam_frontend:e2e-test + + - name: Build or pull backend image + if: needs.detect-changes.outputs.backend == 'true' + uses: docker/build-push-action@v7 + with: + context: components/backend + file: components/backend/Dockerfile + load: true + tags: quay.io/ambient_code/vteam_backend:e2e-test + cache-from: | + type=gha,scope=backend-amd64 + type=gha,scope=e2e-backend + cache-to: type=gha,mode=max,scope=e2e-backend + + - name: Pull backend latest (unchanged) + if: needs.detect-changes.outputs.backend != 'true' + run: | + docker pull quay.io/ambient_code/vteam_backend:latest + docker tag quay.io/ambient_code/vteam_backend:latest quay.io/ambient_code/vteam_backend:e2e-test + + - name: Build or pull operator image + if: needs.detect-changes.outputs.operator == 'true' + uses: docker/build-push-action@v7 + with: + context: components/operator + file: components/operator/Dockerfile + load: true + tags: quay.io/ambient_code/vteam_operator:e2e-test + cache-from: | + type=gha,scope=operator-amd64 + type=gha,scope=e2e-operator + cache-to: type=gha,mode=max,scope=e2e-operator + + - name: Pull operator latest (unchanged) + if: needs.detect-changes.outputs.operator != 'true' + run: | + docker pull quay.io/ambient_code/vteam_operator:latest + docker tag quay.io/ambient_code/vteam_operator:latest quay.io/ambient_code/vteam_operator:e2e-test + + - name: Build or pull ambient-runner image + if: needs.detect-changes.outputs.claude-runner == 'true' + uses: docker/build-push-action@v7 + with: + context: components/runners + file: components/runners/ambient-runner/Dockerfile + load: true + tags: quay.io/ambient_code/vteam_claude_runner:e2e-test + cache-from: | + type=gha,scope=ambient-runner-amd64 + type=gha,scope=e2e-ambient-runner + cache-to: type=gha,mode=max,scope=e2e-ambient-runner + + - name: Pull ambient-runner latest (unchanged) + if: needs.detect-changes.outputs.claude-runner != 'true' + run: | + docker pull quay.io/ambient_code/vteam_claude_runner:latest + docker tag quay.io/ambient_code/vteam_claude_runner:latest quay.io/ambient_code/vteam_claude_runner:e2e-test + + - name: Show built images + run: docker images | grep e2e-test +``` + +- [ ] **Step 2: Validate YAML syntax** + +Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/e2e.yml'))"` +Expected: No output (valid YAML) + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/e2e.yml +git commit -m "ci(e2e): add Docker BuildKit layer caching to image builds + +Read from components-build-deploy cache scopes (frontend-amd64, etc.) +so E2E gets warm layers from the last main build. Falls back to +building uncached if cache misses." +``` + +--- + +### Task 2: Cache kind Binary in E2E + +**Files:** +- Modify: `.github/workflows/e2e.yml:1-10` (add env block) +- Modify: `.github/workflows/e2e.yml:150-155` (replace Install kind step) + +The E2E workflow downloads `kind v0.27.0` from the internet every run. Add an `actions/cache` step matching the pattern already used in `test-local-dev.yml`. + +- [ ] **Step 1: Add KIND_VERSION env var at the workflow level** + +After the `on:` block and before `concurrency:`, add: + +```yaml +env: + KIND_VERSION: "v0.27.0" +``` + +- [ ] **Step 2: Replace the "Install kind" step with a cached version** + +Replace this step: + +```yaml + - name: Install kind + run: | + curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.27.0/kind-linux-amd64 + chmod +x ./kind + sudo mv ./kind /usr/local/bin/kind + kind version +``` + +With: + +```yaml + - name: Cache kind binary + uses: actions/cache@v4 + id: kind-cache + with: + path: ~/k8s-tools/kind + key: kind-${{ runner.os }}-${{ env.KIND_VERSION }} + + - name: Install kind + run: | + mkdir -p ~/k8s-tools + if [[ ! -f ~/k8s-tools/kind ]]; then + echo "Downloading kind $KIND_VERSION..." + curl -sLo ~/k8s-tools/kind "https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-amd64" + chmod +x ~/k8s-tools/kind + else + echo "Using cached kind" + fi + sudo cp ~/k8s-tools/kind /usr/local/bin/kind + kind version +``` + +- [ ] **Step 3: Validate YAML syntax** + +Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/e2e.yml'))"` +Expected: No output (valid YAML) + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/e2e.yml +git commit -m "ci(e2e): cache kind binary between runs + +Pin version in env var for cache key stability. Matches pattern +used in test-local-dev.yml." +``` + +--- + +### Task 3: Consolidate golangci-lint Passes in Lint Workflow + +**Files:** +- Modify: `.github/workflows/lint.yml:144-156` + +The `go-backend` job runs `golangci-lint` twice — once with default tags and once with `--build-tags=test`. The test tag is a superset: files with `//go:build test` are only compiled when the tag is present, so linting with `--build-tags=test` covers all production files plus test-tagged files. Replace two passes with one. + +- [ ] **Step 1: Replace the two golangci-lint steps with one** + +Replace these two steps in the `go-backend` job: + +```yaml + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: latest + working-directory: components/backend + args: --timeout=5m + + - name: Run golangci-lint (test build tags) + uses: golangci/golangci-lint-action@v9 + with: + version: latest + working-directory: components/backend + args: --timeout=5m --build-tags=test +``` + +With a single step: + +```yaml + - name: Run golangci-lint (all build tags) + uses: golangci/golangci-lint-action@v9 + with: + version: latest + working-directory: components/backend + args: --timeout=5m --build-tags=test +``` + +- [ ] **Step 2: Validate YAML syntax** + +Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/lint.yml'))"` +Expected: No output (valid YAML) + +- [ ] **Step 3: Verify locally that test-tagged lint catches everything** + +Run: `cd components/backend && golangci-lint run --timeout=5m --build-tags=test 2>&1 | tail -5` +Expected: Same or superset of issues found by default tags. + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/lint.yml +git commit -m "ci(lint): consolidate golangci-lint to single pass with test tags + +The test build tag is a superset of default — files with +//go:build test are only included when the tag is present. A single +pass with --build-tags=test covers all code." +``` + +--- + +### Task 4: Replace pip-installed junit2html with pipx in Unit Tests + +**Files:** +- Modify: `.github/workflows/unit-tests.yml:131-137` + +The backend unit test job runs `pip install junit2html` without caching on every run. `pipx` is pre-installed on GitHub Actions runners and handles isolated installs. Using `pipx run` avoids the install step entirely — pipx downloads and caches the tool itself. + +- [ ] **Step 1: Replace pip install with pipx run** + +Replace this step: + +```yaml + - name: Install Junit2Html plugin and generate report + if: (!cancelled()) + shell: bash + run: | + pip install junit2html + junit2html ${{ env.TESTS_DIR }}/reports/${{ env.JUNIT_FILENAME }} ${{ env.TESTS_DIR }}/reports/test-report.html + continue-on-error: true +``` + +With: + +```yaml + - name: Generate HTML test report + if: (!cancelled()) + shell: bash + run: | + pipx run junit2html ${{ env.TESTS_DIR }}/reports/${{ env.JUNIT_FILENAME }} ${{ env.TESTS_DIR }}/reports/test-report.html + continue-on-error: true +``` + +- [ ] **Step 2: Validate YAML syntax** + +Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/unit-tests.yml'))"` +Expected: No output (valid YAML) + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/unit-tests.yml +git commit -m "ci(unit-tests): use pipx for junit2html instead of pip install + +pipx is pre-installed on GHA runners and handles caching. Avoids +uncached pip install on every run." +``` diff --git a/docs/superpowers/specs/2026-04-11-ci-improvements-design.md b/docs/superpowers/specs/2026-04-11-ci-improvements-design.md new file mode 100644 index 000000000..107c50c45 --- /dev/null +++ b/docs/superpowers/specs/2026-04-11-ci-improvements-design.md @@ -0,0 +1,143 @@ +# CI Improvements: Reduce PR Feedback Loop + +## Problem + +PR wall-clock time is gated by the slowest workflow. Current P50 times: + +| Workflow | P50 | Primary Bottleneck | +|----------|-----|--------------------| +| E2E Tests | 10.4m | Docker builds from scratch, no layer cache | +| Test Local Dev | 4.9m | Full kind cluster + image build | +| Docker Builds | 3.5m | Already cached (GHA layer cache) | +| Unit Tests | 3.3m | Well-parallelized | +| Lint | 1.6m | golangci-lint runs twice per Go component | + +## Approach + +Two complementary strategies: + +### A: Targeted Caching + Quick Wins + +Incremental improvements across all PR workflows without structural changes. + +### B: E2E Image Reuse + +Restructure E2E to consume images already built by the `components-build-deploy` workflow instead of rebuilding them. + +## Changes + +### 1. E2E Docker Layer Caching (Approach A) + +**File:** `.github/workflows/e2e.yml` + +Replace plain `docker build` commands with `docker buildx build` using GHA cache: + +```yaml +- name: Build frontend + if: needs.detect-changes.outputs.frontend == 'true' + uses: docker/build-push-action@v7 + with: + context: components/frontend + file: components/frontend/Dockerfile + load: true + tags: quay.io/ambient_code/vteam_frontend:e2e-test + cache-from: type=gha,scope=e2e-frontend + cache-to: type=gha,mode=max,scope=e2e-frontend +``` + +Repeat for backend, operator, ambient-runner (4 images total). + +**Expected savings:** 3-5 min on rebuilds (layer cache hits for unchanged layers). + +### 2. Cache kind Binary in E2E (Approach A) + +**File:** `.github/workflows/e2e.yml` + +Currently downloads kind v0.27.0 every run. Add `actions/cache` for the binary, matching the pattern already used in `test-local-dev.yml`. + +Pin version in an env var for cache key stability. + +**Expected savings:** ~10-15s per run. + +### 3. Consolidate golangci-lint Passes (Approach A) + +**File:** `.github/workflows/lint.yml` + +The `go-backend` job runs `golangci-lint-action` twice: +1. Default build tags +2. `--build-tags=test` + +The test tag is a superset — files with `//go:build test` are only compiled when the tag is present, so linting with `--build-tags=test` covers all files. Consolidate to a single pass with `--build-tags=test`. + +This applies only to `go-backend` — the other Go components (operator, api-server, cli) only run golangci-lint once. + +**Expected savings:** ~30s on backend lint. + +### 4. Cache junit2html in Unit Tests (Approach A) + +**File:** `.github/workflows/unit-tests.yml` + +The backend job runs `pip install junit2html` without caching. Add pip caching to the Go job's post-test reporting step, or pre-install in a cached venv. + +Simpler: use `pipx run junit2html` which avoids install entirely (pipx is pre-installed on GitHub runners). + +**Expected savings:** ~10s. + +### 5. E2E Image Reuse from Components Build (Approach B) + +**File:** `.github/workflows/e2e.yml` + +The `components-build-deploy` workflow already builds all images on PR and tags them `pr-`. Instead of E2E rebuilding images, it should: + +1. Add `needs` dependency or use `workflow_run` trigger to wait for components-build +2. Pull pre-built `pr-` images from Quay.io +3. Fall back to local build only if components-build was skipped (no component changes) + +**Implementation options:** + +**Option 1: workflow_run trigger** — E2E triggers after components-build completes. Clean separation but adds latency waiting for the full build matrix (14 jobs). + +**Option 2: Direct pull in E2E job** — E2E attempts to pull `pr-` tagged images. If pull fails (image not pushed yet or components-build skipped), falls back to local build. This is simpler and doesn't create a hard dependency. + +**Recommendation: Option 2 (direct pull with fallback).** The components-build workflow only pushes on non-PR events currently (`if: github.event_name != 'pull_request'`), so we need to either: +- Enable PR image pushes in components-build (to a PR-specific tag), or +- Use the GHA Docker layer cache directly (E2E reads from same cache scope as components-build) + +The cleanest path: **share GHA cache scopes.** E2E's `cache-from` references the same scope that components-build writes to. No image push/pull needed — just shared BuildKit cache layers. + +```yaml +# In e2e.yml - reuse cache from components-build +cache-from: | + type=gha,scope=frontend-amd64 + type=gha,scope=e2e-frontend +cache-to: type=gha,mode=max,scope=e2e-frontend +``` + +This way E2E gets warm cache from the last components-build run on main, plus its own cache from prior E2E runs. + +**Expected savings:** 3-5 min (near-instant builds when layers haven't changed). + +## What We're NOT Changing + +- **Test Local Dev workflow:** Already well-optimized with k8s tools caching. The ~5 min is inherent to kind cluster bootstrap + image build + deploy. No easy wins without fundamentally changing the approach. +- **Workflow consolidation:** Not merging lint + unit-tests. The separation is clean and the overhead is marginal (~30s for detect-changes + summary). +- **Components-build-deploy push behavior:** Not enabling image pushes on PRs — the shared cache approach achieves the same benefit without registry overhead. + +## Risk Assessment + +| Change | Risk | Mitigation | +|--------|------|------------| +| Docker layer caching in E2E | Low — additive, fallback is uncached build | GHA cache has 10GB limit; scope names prevent collision | +| kind caching | None — identical pattern to test-local-dev | Pin version in env var | +| golangci-lint consolidation | Low — test tag is superset | Verify locally that `--build-tags=test` finds all issues | +| junit2html via pipx | None — pipx pre-installed on runners | Can fall back to pip install | +| Shared cache scopes | Medium — cache eviction if 10GB limit hit | Use `mode=max` and scope naming to partition | + +## Expected Outcome + +| Workflow | Current P50 | Expected P50 | Savings | +|----------|-------------|--------------|---------| +| E2E Tests | 10.4m | ~5-7m | 3-5m | +| Lint | 1.6m | ~1.2m | ~30s | +| Unit Tests | 3.3m | ~3.1m | ~10s | +| **PR wall clock** | **~10.4m** | **~5-7m** | **3-5m** |