feat(operator): self-register and manage the host cluster #15566
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI - KSail | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| merge_group: | |
| workflow_dispatch: | |
| inputs: | |
| run_system_tests: | |
| description: "Run system tests" | |
| required: false | |
| type: boolean | |
| default: true | |
| env: | |
| # Use pipe (|) instead of comma (,) so Go falls through to direct on ANY proxy | |
| # error (including 403), not just 404/410. Hardens CI against transient | |
| # proxy.golang.org outages. go.sum still verifies module integrity. | |
| GOPROXY: "https://proxy.golang.org|direct" | |
| # Soft memory limit for Go build toolchain (compiler, linker). The KSail | |
| # binary links a large dependency tree and the Go linker can exceed the | |
| # default runner memory on cache-miss builds. Setting GOMEMLIMIT makes the | |
| # GC work harder to stay within budget, reducing peak RSS. | |
| GOMEMLIMIT: "6GiB" | |
| concurrency: | |
| group: "ci-ksail-${{ github.workflow }}-${{ github.ref }}" | |
| cancel-in-progress: true | |
| permissions: {} | |
| jobs: | |
| # cancel-stale-merge-queue — kept for future use when jobs are moved to merge_group | |
| # cancel-stale-merge-queue: | |
| # name: 🧹 Cancel Stale Merge Queue Runs | |
| # if: github.event_name == 'merge_group' | |
| # runs-on: ubuntu-latest | |
| # timeout-minutes: 2 | |
| # permissions: | |
| # actions: write | |
| # steps: | |
| # - name: Cancel previous runs for same merge queue entry | |
| # uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| # with: | |
| # script: | | |
| # const ref = context.ref; | |
| # const match = ref.match(/^(refs\/heads\/gh-readonly-queue\/.+)-[0-9a-f]+$/); | |
| # if (!match) { | |
| # console.log('Not a merge queue ref, skipping'); | |
| # return; | |
| # } | |
| # const stablePrefix = match[1]; | |
| # | |
| # for (const status of ['in_progress', 'queued']) { | |
| # let page = 1; | |
| # while (true) { | |
| # const { data: { workflow_runs: runs } } = await github.rest.actions.listWorkflowRuns({ | |
| # owner: context.repo.owner, | |
| # repo: context.repo.repo, | |
| # workflow_id: 'ci.yaml', | |
| # event: 'merge_group', | |
| # status, | |
| # per_page: 100, | |
| # page, | |
| # }); | |
| # | |
| # if (runs.length === 0) break; | |
| # | |
| # for (const run of runs) { | |
| # if (run.run_number >= context.runNumber) continue; | |
| # const runRef = `refs/heads/${run.head_branch}`; | |
| # const runMatch = runRef.match(/^(refs\/heads\/gh-readonly-queue\/.+)-[0-9a-f]+$/); | |
| # if (runMatch && runMatch[1] === stablePrefix) { | |
| # console.log(`Cancelling stale run ${run.id} (${runRef})`); | |
| # try { | |
| # await github.rest.actions.cancelWorkflowRun({ | |
| # owner: context.repo.owner, | |
| # repo: context.repo.repo, | |
| # run_id: run.id, | |
| # }); | |
| # } catch (error) { | |
| # if (error && (error.status === 409 || error.status === 422)) { | |
| # console.log(`Skipping run ${run.id}; it is no longer cancellable (${error.status})`); | |
| # continue; | |
| # } | |
| # throw error; | |
| # } | |
| # } | |
| # } | |
| # | |
| # if (runs.length < 100) break; | |
| # page += 1; | |
| # } | |
| # } | |
| # Wait for sufficient GitHub API rate limit before proceeding | |
| rate-limit-gate: | |
| name: ⏳ Rate Limit Gate | |
| if: github.event_name != 'merge_group' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: 📄 Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| persist-credentials: false | |
| - uses: ./.github/actions/rate-limit-gate | |
| # Detect which file categories changed to conditionally run jobs | |
| changes: | |
| name: 🔍 Detect Changes | |
| if: github.event_name != 'merge_group' | |
| needs: [rate-limit-gate] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| outputs: | |
| code: ${{ steps.filter.outputs.code }} | |
| benchmark: ${{ steps.filter.outputs.benchmark }} | |
| system-test: ${{ steps.filter.outputs.system-test }} | |
| schema: ${{ steps.filter.outputs.schema }} | |
| cli: ${{ steps.filter.outputs.cli }} | |
| vsce: ${{ steps.filter.outputs.vsce }} | |
| docs: ${{ steps.filter.outputs.docs }} | |
| docs-deps: ${{ steps.filter.outputs.docs-deps }} | |
| vsce-deps: ${{ steps.filter.outputs.vsce-deps }} | |
| copilot-plugin: ${{ steps.filter.outputs.copilot-plugin }} | |
| operator-chart: ${{ steps.filter.outputs.operator-chart }} | |
| desktop: ${{ steps.filter.outputs.desktop }} | |
| steps: | |
| - name: 📄 Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| persist-credentials: false | |
| - name: 🔍 Filter paths | |
| uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 | |
| id: filter | |
| with: | |
| filters: | | |
| code: | |
| - '**/*.go' | |
| - 'go.mod' | |
| - 'go.sum' | |
| - '.github/actions/**' | |
| benchmark: | |
| # Limit to packages that contain benchmarks plus module files. | |
| # Avoids paying ~30 min of benchmark cost on PRs that touch only | |
| # unrelated Go code. Keep this in sync with the package discovery | |
| # step in the `benchmark` job (`grep -rl '^func Benchmark'`). | |
| - 'pkg/apis/cluster/v1alpha1/**/*.go' | |
| - 'pkg/cli/cmd/cipher/**/*.go' | |
| - 'pkg/cli/cmd/cluster/**/*.go' | |
| - 'pkg/client/argocd/**/*.go' | |
| - 'pkg/client/docker/**/*.go' | |
| - 'pkg/client/flux/**/*.go' | |
| - 'pkg/client/helm/**/*.go' | |
| - 'pkg/client/kubectl/**/*.go' | |
| - 'pkg/client/kustomize/**/*.go' | |
| - 'pkg/fsutil/configmanager/ksail/**/*.go' | |
| - 'pkg/fsutil/marshaller/**/*.go' | |
| - 'pkg/k8s/readiness/**/*.go' | |
| - 'pkg/notify/**/*.go' | |
| - 'pkg/svc/diff/**/*.go' | |
| - 'pkg/svc/image/**/*.go' | |
| - 'pkg/svc/registryresolver/**/*.go' | |
| - 'go.mod' | |
| - 'go.sum' | |
| - '.github/workflows/ci.yaml' | |
| system-test: | |
| # NOTE: Do not use negative patterns (!) here. | |
| # dorny/paths-filter v4 uses picomatch where negated patterns | |
| # like '!**/*_test.go' match ANY file that is not a test file | |
| # (including .mdx, .json, etc.), causing docs-only PRs to | |
| # falsely trigger system tests. | |
| # Use an explicit allowlist of directories whose changes truly | |
| # require system tests (cluster/workload code, providers, | |
| # provisioners, installers, client libraries). Chat, MCP, | |
| # cipher, toolgen, docs generators, and schema generators are | |
| # intentionally omitted. | |
| - 'main.go' | |
| - 'internal/buildmeta/**' | |
| - 'pkg/apis/**' | |
| - 'pkg/cli/annotations/**' | |
| - 'pkg/cli/cmd/cluster/**' | |
| - 'pkg/cli/cmd/workload/**' | |
| - 'pkg/cli/cmd/tenant/**' | |
| - 'pkg/cli/dockerutil/**' | |
| - 'pkg/cli/editor/**' | |
| - 'pkg/cli/flags/**' | |
| - 'pkg/cli/kubeconfig/**' | |
| - 'pkg/cli/kubeconfighook/**' | |
| - 'pkg/cli/lifecycle/**' | |
| - 'pkg/cli/setup/**' | |
| - 'pkg/cli/ui/asciiart/**' | |
| - 'pkg/cli/ui/confirm/**' | |
| - 'pkg/cli/ui/errorhandler/**' | |
| - 'pkg/cli/ui/picker/**' | |
| - 'pkg/client/**' | |
| - 'pkg/di/**' | |
| - 'pkg/envvar/**' | |
| - 'pkg/fsutil/**' | |
| - 'pkg/k8s/**' | |
| - 'pkg/notify/**' | |
| - 'pkg/runner/**' | |
| - 'pkg/svc/detector/**' | |
| - 'pkg/svc/diff/**' | |
| - 'pkg/svc/image/**' | |
| - 'pkg/svc/installer/**' | |
| - 'pkg/svc/provider/**' | |
| - 'pkg/svc/provisioner/**' | |
| - 'pkg/svc/registryresolver/**' | |
| - 'pkg/svc/state/**' | |
| - 'pkg/svc/tenant/**' | |
| - 'pkg/svc/versionresolver/**' | |
| - 'pkg/timer/**' | |
| - 'go.mod' | |
| - 'go.sum' | |
| - '.github/actions/**' | |
| - '.github/fixtures/**' | |
| - '.github/workflows/ci.yaml' | |
| schema: | |
| - 'pkg/apis/**/*.go' | |
| - 'go.mod' | |
| - 'schemas/gen_schema.go' | |
| cli: | |
| - 'cmd/**/*.go' | |
| - 'pkg/**/*.go' | |
| - 'go.mod' | |
| - 'docs/gen_docs.go' | |
| - 'docs/gen_docs_prose.go' | |
| vsce: | |
| - 'vsce/**' | |
| - '.github/workflows/ci.yaml' | |
| docs: | |
| # Matches docs content (MDX, Astro, config) but not Go generators. | |
| # Avoid negative patterns — see system-test comment above. | |
| - 'docs/src/**' | |
| - 'docs/public/**' | |
| - 'docs/astro.config.*' | |
| - 'docs/tsconfig.json' | |
| - 'docs/package*.json' | |
| - 'docs/.npmrc' | |
| - '.github/workflows/ci.yaml' | |
| docs-deps: | |
| - 'docs/package*.json' | |
| vsce-deps: | |
| - 'vsce/package*.json' | |
| copilot-plugin: | |
| - 'copilot-plugin/**' | |
| - '.github/plugin/**' | |
| - '.claude-plugin/**' | |
| - '.github/workflows/ci.yaml' | |
| operator-chart: | |
| # Operator Helm chart E2E. Covers the chart, the web UI, the | |
| # operator runtime (`ksail operator` subcommand), the reconciler, | |
| # the Cluster CRD types, and the image build inputs. | |
| - 'charts/ksail-operator/**' | |
| - 'web/ui/**' | |
| - 'pkg/operator/**' | |
| - 'internal/controller/**' | |
| - 'pkg/cli/cmd/operator/**' | |
| - 'pkg/apis/**' | |
| - 'Dockerfile' | |
| - '.github/actions/operator-chart-e2e/**' | |
| - '.github/workflows/ci.yaml' | |
| desktop: | |
| # The desktop app is a SEPARATE Go module that vendors the main | |
| # module via `replace => ../`, so its go.{mod,sum} must track the | |
| # main module's dependency graph independently. A root dependency | |
| # bump (go.mod/go.sum) does NOT touch desktop/**, so it must be | |
| # listed here or desktop drift slips through to release. Keep | |
| # go.mod/go.sum in this filter. | |
| - 'desktop/**' | |
| - 'go.mod' | |
| - 'go.sum' | |
| - '.github/workflows/ci.yaml' | |
| ci-go: | |
| name: ✅ Validate Go Project | |
| if: github.event_name == 'push' && github.ref == 'refs/heads/main' | |
| uses: devantler-tech/reusable-workflows/.github/workflows/validate-go-project.yaml@6b44cfb837e545a8179b0cc97a63b7c694cb9287 # v5.5.7 | |
| permissions: | |
| contents: write | |
| issues: write | |
| pull-requests: write | |
| code-quality: write | |
| secrets: | |
| APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} | |
| # Gate: wait for the org-wide Required Workflow "✅ Validate Go Project" to | |
| # finish before starting expensive system tests. On workflow_dispatch the | |
| # required workflow does not run, so this job is skipped. | |
| wait-for-validate-go: | |
| name: ⏳ Wait for Go Validation | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| needs: [changes] | |
| if: >- | |
| github.event_name == 'pull_request' | |
| && needs.changes.outputs.system-test == 'true' | |
| && github.event.pull_request.head.repo.full_name == github.repository | |
| permissions: | |
| checks: read | |
| steps: | |
| - name: Wait for ✅ Validate Go Project | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| with: | |
| script: | | |
| const sha = context.payload.pull_request.head.sha; | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const prefix = '✅ Validate Go Project'; | |
| const maxWait = 25 * 60 * 1000; // 25 minutes | |
| const poll = 30 * 1000; // 30 seconds | |
| const start = Date.now(); | |
| while (Date.now() - start < maxWait) { | |
| const { data: checks } = await github.rest.checks.listForRef({ | |
| owner, repo, ref: sha, per_page: 100, | |
| }); | |
| const relevant = checks.check_runs.filter(cr => | |
| cr.name.startsWith(prefix) | |
| ); | |
| if (relevant.length > 0) { | |
| const pending = relevant.filter(cr => cr.status !== 'completed'); | |
| if (pending.length === 0) { | |
| const failed = relevant.filter(cr => | |
| !['success', 'skipped', 'neutral'].includes(cr.conclusion) | |
| ); | |
| if (failed.length > 0) { | |
| const names = failed.map(cr => `${cr.name}: ${cr.conclusion}`).join('\n'); | |
| core.setFailed(`Go validation failed:\n${names}`); | |
| return; | |
| } | |
| core.info(`✅ All ${relevant.length} Go validation check(s) passed`); | |
| return; | |
| } | |
| core.info(`⏳ Waiting for ${pending.length}/${relevant.length} check(s)...`); | |
| } else { | |
| core.info('⏳ No Go validation checks found yet...'); | |
| } | |
| await new Promise(r => setTimeout(r, poll)); | |
| } | |
| // On timeout, succeed gracefully if no checks ever appeared | |
| // (required workflow may not be configured for this repo). | |
| const { data: final } = await github.rest.checks.listForRef({ | |
| owner, repo, ref: sha, per_page: 100, | |
| }); | |
| const remaining = final.check_runs.filter(cr => | |
| cr.name.startsWith(prefix) | |
| ); | |
| if (remaining.length === 0) { | |
| core.warning('No Go validation checks found — required workflow may not apply to this repo'); | |
| return; | |
| } | |
| core.setFailed('Timed out waiting for Go validation checks to complete'); | |
| build-artifact: | |
| name: 🏗️ Build KSail Binary | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| needs: [changes] | |
| if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && (needs.changes.outputs.code == 'true' || needs.changes.outputs.cli == 'true' || needs.changes.outputs.system-test == 'true')) | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: 📄 Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| persist-credentials: false | |
| - name: ⚙️ Setup Go | |
| id: setup-go | |
| uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 | |
| with: | |
| go-version-file: go.mod | |
| - name: 📦 Cache KSail Binary | |
| uses: ./.github/actions/cache-ksail-binary | |
| with: | |
| go-version: ${{ steps.setup-go.outputs.go-version }} | |
| source-hash: ${{ hashFiles('go.mod', 'go.sum', '**/*.go') }} | |
| output-path: /usr/local/bin/ksail | |
| generate: | |
| name: 📝 Generate Schema, CRD & Docs | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 25 | |
| # Depend on build-artifact so the Go build cache is warm by the time | |
| # `go generate ./docs/...` (and ./schemas/...) re-compiles the cli import | |
| # graph. On events where build-artifact is skipped (push to main), this | |
| # falls through and the job still runs. | |
| needs: [changes, build-artifact] | |
| if: >- | |
| !cancelled() | |
| && github.event_name != 'merge_group' | |
| && (needs.changes.outputs.schema == 'true' || needs.changes.outputs.cli == 'true') | |
| && (needs.build-artifact.result == 'success' || needs.build-artifact.result == 'skipped') | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: 📄 Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| persist-credentials: false | |
| repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }} | |
| ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} | |
| - name: 🧹 Free disk space | |
| # `go generate ./docs/...` re-links the full embedded-CLI import graph | |
| # (kubectl, helm, kind, k3d, vcluster, flux, argocd, eksctl, …) into a | |
| # large binary; on a stock ubuntu-latest runner the linker can exhaust | |
| # disk ("no space left on device"). Reclaim space generation does not | |
| # need before Go is set up. Mirrors the system-test job's step (no | |
| # `docker system prune` here — this job never touches Docker). Each | |
| # removal is best-effort: missing paths are not an error. | |
| run: | | |
| set -euo pipefail | |
| before=$(df --output=avail -BG / | tail -1 | tr -dc '0-9') | |
| sudo rm -rf \ | |
| /usr/local/lib/android \ | |
| /usr/share/dotnet \ | |
| /opt/ghc \ | |
| /usr/local/share/boost \ | |
| /usr/share/swift \ | |
| /opt/hostedtoolcache/CodeQL \ | |
| /opt/hostedtoolcache/PyPy \ | |
| /opt/hostedtoolcache/Ruby \ | |
| /opt/hostedtoolcache/Python \ | |
| /usr/local/share/chromium \ | |
| /usr/local/share/powershell \ | |
| /usr/local/julia* \ | |
| /usr/local/aws-cli \ | |
| /usr/local/aws-sam-cli \ | |
| /usr/share/gradle* \ | |
| /usr/share/az* \ | |
| /usr/share/miniconda || true | |
| after=$(df --output=avail -BG / | tail -1 | tr -dc '0-9') | |
| echo "Free disk space on /: ${before}G -> ${after}G (gained $((after - before))G)" | |
| - name: ⚙️ Setup Go | |
| uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 | |
| with: | |
| go-version-file: go.mod | |
| - name: 📝 Generate JSON schema | |
| if: needs.changes.outputs.schema == 'true' | |
| run: go generate ./schemas/... | |
| - name: 📐 Generate CRD & DeepCopy | |
| if: needs.changes.outputs.schema == 'true' | |
| run: go generate ./pkg/apis/... | |
| - name: 📚 Generate reference documentation | |
| if: needs.changes.outputs.cli == 'true' | |
| run: go generate ./docs/... | |
| - name: 📤 Upload patch | |
| run: | | |
| git add -N . | |
| git diff > /tmp/generate.patch | |
| if [ -s /tmp/generate.patch ]; then | |
| echo "Changes detected" | |
| else | |
| echo "No changes" | |
| fi | |
| - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: generate-patch | |
| path: /tmp/generate.patch | |
| if-no-files-found: ignore | |
| retention-days: 1 | |
| - name: ❌ Fail if generated files are out of date (fork PR) | |
| if: >- | |
| github.event_name == 'pull_request' | |
| && github.event.pull_request.head.repo.full_name != github.repository | |
| shell: bash | |
| run: | | |
| if [ -s /tmp/generate.patch ]; then | |
| echo "::error::Generated files are out of date. Please run 'go generate ./schemas/...', 'go generate ./pkg/apis/...', and 'go generate ./docs/...' locally and commit the results." | |
| echo "" | |
| cat /tmp/generate.patch | |
| exit 1 | |
| fi | |
| auto-commit: | |
| name: 📤 Auto-Commit Generated Changes | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| needs: [changes, generate, verify-desktop-tidy] | |
| if: >- | |
| always() | |
| && github.event_name != 'merge_group' | |
| && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) | |
| && (needs.generate.result == 'success' || needs.verify-desktop-tidy.result == 'success') | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| steps: | |
| - name: 🔑 Generate GitHub App Token | |
| uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 | |
| id: generate-token | |
| with: | |
| client-id: ${{ vars.APP_CLIENT_ID }} | |
| private-key: ${{ secrets.APP_PRIVATE_KEY }} # zizmor: ignore[secrets-outside-env] - GitHub App token generation, no environment scoping available for app installations | |
| - name: 📄 Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| ref: ${{ github.head_ref || github.ref_name }} | |
| persist-credentials: true | |
| token: ${{ steps.generate-token.outputs.token }} | |
| - name: 📥 Download patches | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| pattern: "*-patch" | |
| merge-multiple: true | |
| path: /tmp/patches | |
| - name: 📋 Apply patches | |
| run: | | |
| set -euo pipefail | |
| shopt -s nullglob | |
| for patch in /tmp/patches/*.patch; do | |
| if [ -s "$patch" ]; then | |
| patch_name="$(basename "$patch")" | |
| echo "Applying ${patch_name}..." | |
| if git apply --3way "$patch"; then | |
| echo "Applied ${patch_name} with 3-way merge." | |
| else | |
| echo "3-way apply failed for ${patch_name}; falling back to direct apply with rejects..." >&2 | |
| if git apply --reject "$patch"; then | |
| echo "Applied ${patch_name} with rejects." | |
| rej_files=$(find . -name '*.rej' 2>/dev/null || true) | |
| if [ -n "$rej_files" ]; then | |
| echo "::error::Reject files found after applying ${patch_name}:" | |
| echo "${rej_files}" | |
| git status --short || true | |
| echo "Failing job to avoid committing partially-applied patches." >&2 | |
| exit 1 | |
| fi | |
| else | |
| echo "::error::Failed to apply patch ${patch_name}. See diagnostics above." >&2 | |
| git status --short || true | |
| exit 1 | |
| fi | |
| fi | |
| else | |
| echo "Skipping empty patch $(basename "$patch")" | |
| fi | |
| done | |
| - name: 📤 Commit and push generated changes (PR branch) | |
| if: github.event_name == 'pull_request' | |
| uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0 | |
| with: | |
| commit_message: "chore: sync modules and update generated files" | |
| commit_user_name: generator-bot | |
| commit_user_email: generator-bot@users.noreply.github.com | |
| branch: ${{ github.head_ref }} | |
| - name: 📤 Open PR for generated changes (protected branch) | |
| if: github.event_name != 'pull_request' | |
| uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 | |
| with: | |
| token: ${{ steps.generate-token.outputs.token }} | |
| commit-message: "chore: sync modules and update generated files" | |
| committer: "generator-bot <generator-bot@users.noreply.github.com>" | |
| branch: chore/sync-generated-files/${{ github.ref_name }} | |
| base: ${{ github.ref_name }} | |
| title: "chore: sync modules and update generated files" | |
| body: | | |
| Auto-generated changes detected on `${{ github.ref_name }}`. | |
| This PR updates generated files (schemas, docs, etc.) that were | |
| not included in the originating commit. | |
| delete-branch: true | |
| license-check: | |
| name: 🔍 License Check | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| needs: [changes] | |
| if: github.event_name != 'merge_group' && needs.changes.outputs.code == 'true' | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: 📄 Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| persist-credentials: false | |
| - name: ⚙️ Setup Go | |
| uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 | |
| with: | |
| go-version-file: go.mod | |
| - name: 📦 Install go-licenses | |
| run: go install github.com/google/go-licenses/v2@v2.0.1 | |
| - name: 🔍 Check dependency licenses | |
| run: | | |
| # --ignore flags below exclude transitive dependencies whose licenses | |
| # exist at the repository root but are not discoverable by go-licenses | |
| # at the sub-package level (false-positive "license not found" errors). | |
| go-licenses check ./... \ | |
| --disallowed_types=forbidden \ | |
| --ignore github.com/devantler-tech/ksail/v7 \ | |
| --ignore github.com/spdx/tools-golang \ | |
| --ignore github.com/in-toto/in-toto-golang \ | |
| --ignore github.com/in-toto/attestation \ | |
| --ignore github.com/inconshreveable/go-update \ | |
| --ignore github.com/loft-sh/admin-apis \ | |
| --ignore github.com/segmentio/asm \ | |
| --ignore github.com/alibabacloud-go/cr-20160607 \ | |
| --ignore github.com/cyberphone/json-canonicalization \ | |
| --ignore github.com/deitch/magic | |
| home-isolation: | |
| name: 🏠 Home Isolation Guard | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 25 | |
| needs: [changes] | |
| if: github.event_name != 'merge_group' && needs.changes.outputs.code == 'true' | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: 📄 Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| persist-credentials: false | |
| - name: ⚙️ Setup Go | |
| uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 | |
| with: | |
| go-version-file: go.mod | |
| - name: 🏠 Verify tests never touch the real home directory | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| # Preserve Go caches so redirecting $HOME does not trigger a cold | |
| # rebuild and so build/module artifacts land outside the sandbox. | |
| GOCACHE="$(go env GOCACHE)" | |
| GOMODCACHE="$(go env GOMODCACHE)" | |
| GOPATH="$(go env GOPATH)" | |
| export GOCACHE GOMODCACHE GOPATH | |
| sandbox="$(mktemp -d)" | |
| trap 'rm -rf "$sandbox"' EXIT | |
| mkdir -p "$sandbox/.kube" "$sandbox/.ksail" | |
| cat > "$sandbox/.kube/config" <<'EOF' | |
| apiVersion: v1 | |
| kind: Config | |
| current-context: admin@home-isolation-guard | |
| clusters: | |
| - cluster: | |
| server: https://localhost:6443 | |
| name: home-isolation-guard | |
| contexts: | |
| - context: | |
| cluster: home-isolation-guard | |
| user: admin@home-isolation-guard | |
| name: admin@home-isolation-guard | |
| users: | |
| - name: admin@home-isolation-guard | |
| EOF | |
| printf '{"recent":["__home_isolation_guard__"]}' > "$sandbox/.ksail/switch-history.json" | |
| kube_before="$(sha256sum "$sandbox/.kube/config" | cut -d' ' -f1)" | |
| hist_before="$(sha256sum "$sandbox/.ksail/switch-history.json" | cut -d' ' -f1)" | |
| tree_before="$(find "$sandbox/.kube" "$sandbox/.ksail" | sort)" | |
| # Run the whole suite with $HOME redirected to the sandbox. Test | |
| # pass/fail is the main test workflow's job; this guard only asserts | |
| # that no test mutated the real home directory, so failures here are | |
| # tolerated. | |
| HOME="$sandbox" go test ./... >/dev/null 2>&1 || true | |
| # Capture the post-run state with errexit disabled: a test that | |
| # deletes the canary files/dirs is itself a violation, and the empty | |
| # captures below will differ from the "before" values and trip the | |
| # actionable ::error:: messages rather than aborting the step. | |
| set +e | |
| kube_after="$(sha256sum "$sandbox/.kube/config" 2>/dev/null | cut -d' ' -f1)" | |
| hist_after="$(sha256sum "$sandbox/.ksail/switch-history.json" 2>/dev/null | cut -d' ' -f1)" | |
| tree_after="$(find "$sandbox/.kube" "$sandbox/.ksail" 2>/dev/null | sort)" | |
| set -e | |
| status=0 | |
| if [ "$kube_before" != "$kube_after" ]; then | |
| echo "::error::A test mutated \$HOME/.kube/config. Add a homeenv.Run/Isolate TestMain to the offending package (see internal/testutil/homeenv)." | |
| status=1 | |
| fi | |
| if [ "$hist_before" != "$hist_after" ]; then | |
| echo "::error::A test mutated \$HOME/.ksail/switch-history.json. Add a homeenv.Run/Isolate TestMain to the offending package." | |
| status=1 | |
| fi | |
| if [ "$tree_before" != "$tree_after" ]; then | |
| echo "::error::A test created or removed files under \$HOME/.kube or \$HOME/.ksail. Add a homeenv.Run/Isolate TestMain to the offending package." | |
| diff <(printf '%s\n' "$tree_before") <(printf '%s\n' "$tree_after") || true | |
| status=1 | |
| fi | |
| exit "$status" | |
| audit-docs: | |
| name: 🔍 Audit Docs Dependencies | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| needs: [changes] | |
| if: github.event_name != 'merge_group' && needs.changes.outputs.docs-deps == 'true' | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: 📄 Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| persist-credentials: false | |
| - name: 🔍 NPM Audit and Fix | |
| uses: ./.github/actions/npm-audit-and-fix | |
| with: | |
| working-directory: docs | |
| client-id: ${{ vars.APP_CLIENT_ID }} | |
| app-private-key: ${{ secrets.APP_PRIVATE_KEY }} # zizmor: ignore[secrets-outside-env] - GitHub App token generation, no environment scoping available for app installations | |
| commit-message: "chore(docs): fix npm audit vulnerabilities" | |
| audit-vsce: | |
| name: 🔍 Audit VSCode Extension Dependencies | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| needs: [changes] | |
| if: github.event_name != 'merge_group' && needs.changes.outputs.vsce-deps == 'true' | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: 📄 Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| persist-credentials: false | |
| - name: 🔍 NPM Audit and Fix | |
| uses: ./.github/actions/npm-audit-and-fix | |
| with: | |
| working-directory: vsce | |
| client-id: ${{ vars.APP_CLIENT_ID }} | |
| app-private-key: ${{ secrets.APP_PRIVATE_KEY }} # zizmor: ignore[secrets-outside-env] - GitHub App token generation, no environment scoping available for app installations | |
| commit-message: "chore(vsce): fix npm audit vulnerabilities" | |
| build-docs: | |
| name: 📚 Build Documentation | |
| runs-on: ubuntu-latest | |
| needs: [changes] | |
| if: github.event_name == 'pull_request' && needs.changes.outputs.docs == 'true' | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: 📄 Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| persist-credentials: false | |
| - name: ⚙️ Setup Node.js | |
| uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | |
| with: | |
| node-version: "24" | |
| cache: "npm" | |
| cache-dependency-path: docs/package-lock.json | |
| - name: 📦 Install dependencies | |
| working-directory: docs | |
| run: npm ci | |
| - name: 🏗️ Build with Astro | |
| working-directory: docs | |
| run: npm run build | |
| vscode-extension: | |
| name: 🧩 VSCode Extension | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| needs: [changes] | |
| if: github.event_name != 'merge_group' && needs.changes.outputs.vsce == 'true' | |
| permissions: | |
| contents: read | |
| defaults: | |
| run: | |
| working-directory: vsce | |
| steps: | |
| - name: 📄 Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| persist-credentials: false | |
| - name: ⚙️ Setup Node.js | |
| uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | |
| with: | |
| node-version: "24" | |
| cache: "npm" | |
| cache-dependency-path: "vsce/package-lock.json" | |
| - name: 📦 Install dependencies | |
| run: npm ci | |
| - name: 🏗️ Compile | |
| run: npm run compile | |
| - name: 📦 Package extension | |
| run: npm run package | |
| - name: 📦 Create VSIX package | |
| if: github.event_name == 'pull_request' || github.event_name == 'merge_group' | |
| run: npx @vscode/vsce package --out ksail.vsix | |
| - name: 📤 Upload VSIX artifact | |
| if: github.event_name == 'pull_request' || github.event_name == 'merge_group' | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: ksail-vsix | |
| path: ksail.vsix | |
| retention-days: 7 | |
| copilot-plugin: | |
| name: 🧩 Copilot CLI Plugin | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| needs: [changes] | |
| if: github.event_name != 'merge_group' && needs.changes.outputs.copilot-plugin == 'true' | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: 📄 Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| persist-credentials: false | |
| - name: 🧪 Validate plugin manifests | |
| run: | | |
| set -euo pipefail | |
| for f in \ | |
| copilot-plugin/plugin.json \ | |
| copilot-plugin/.claude-plugin/plugin.json \ | |
| copilot-plugin/.mcp.json \ | |
| .github/plugin/marketplace.json \ | |
| .claude-plugin/marketplace.json; do | |
| echo "Validating $f" | |
| jq -e . "$f" >/dev/null | |
| done | |
| # Required fields on the Copilot CLI + Claude Code plugin manifests | |
| for f in copilot-plugin/plugin.json copilot-plugin/.claude-plugin/plugin.json; do | |
| jq -e '.name == "ksail" and .version' "$f" >/dev/null | |
| done | |
| # Each marketplace must reference the plugin directory | |
| for f in .github/plugin/marketplace.json .claude-plugin/marketplace.json; do | |
| jq -e '.plugins[0].source == "./copilot-plugin" and .plugins[0].name == "ksail"' "$f" >/dev/null | |
| done | |
| # Skill manifest must exist | |
| test -f copilot-plugin/skills/ksail/SKILL.md | |
| # MCP server must point at the ksail binary | |
| jq -e '.mcpServers.ksail.command == "ksail"' copilot-plugin/.mcp.json >/dev/null | |
| operator-chart-lint: | |
| name: ⛵ Operator Chart Lint | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| needs: [changes] | |
| if: github.event_name != 'merge_group' && needs.changes.outputs.operator-chart == 'true' | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: 📄 Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| persist-credentials: false | |
| - name: 🔍 Helm lint | |
| run: helm lint charts/ksail-operator | |
| - name: 📐 Helm template (value permutations) | |
| run: | | |
| set -euo pipefail | |
| echo "=== defaults (operator only) ===" | |
| helm template ksail-operator charts/ksail-operator >/dev/null | |
| echo "=== ui.ingress.enabled ===" | |
| helm template ksail-operator charts/ksail-operator \ | |
| --set ui.ingress.enabled=true >/dev/null | |
| echo "=== auth.oidc.enabled ===" | |
| # OIDC requires a redirectURL (or ui.ingress) plus a client secret; | |
| # the operator does not run here, so dummy values are sufficient to | |
| # exercise the secret/deployment templates. | |
| helm template ksail-operator charts/ksail-operator \ | |
| --set ui.ingress.enabled=true \ | |
| --set auth.oidc.enabled=true \ | |
| --set auth.oidc.issuerURL=https://dex.example \ | |
| --set auth.oidc.clientID=ksail \ | |
| --set auth.oidc.clientSecret=dummy \ | |
| --set auth.oidc.redirectURL=https://ksail.local/api/v1/auth/callback >/dev/null | |
| echo "✅ All chart template permutations rendered" | |
| operator-chart-e2e: | |
| name: ⛵ Operator Chart E2E | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| needs: [changes] | |
| # Local images only — no secrets/GHCR — so this also runs on fork PRs. | |
| if: >- | |
| !cancelled() | |
| && ( | |
| (github.event_name == 'pull_request' && needs.changes.outputs.operator-chart == 'true') | |
| || github.event_name == 'workflow_dispatch' | |
| ) | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: 📄 Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| persist-credentials: false | |
| - name: ⚙️ Setup Go | |
| uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 | |
| with: | |
| go-version-file: go.mod | |
| - name: 🧪 Run Operator Chart E2E | |
| uses: ./.github/actions/operator-chart-e2e | |
| warm-helm-cache: | |
| name: 🔥 Warm Helm Cache | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| needs: [changes, license-check, build-artifact, wait-for-validate-go] | |
| if: >- | |
| !cancelled() | |
| && (needs.wait-for-validate-go.result == 'success' || needs.wait-for-validate-go.result == 'skipped') | |
| && ( | |
| (github.event_name == 'pull_request' | |
| && needs.changes.outputs.system-test == 'true' | |
| && github.event.pull_request.head.repo.full_name == github.repository) | |
| || (github.event_name == 'workflow_dispatch' && inputs.run_system_tests == true) | |
| ) | |
| permissions: | |
| contents: read | |
| outputs: | |
| cache-key: ${{ steps.warm.outputs.cache-key }} | |
| steps: | |
| - name: 📄 Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| persist-credentials: false | |
| - name: ⚙️ Setup Go | |
| id: setup-go | |
| uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 | |
| with: | |
| go-version-file: go.mod | |
| - name: 📦 Cache KSail Binary | |
| uses: ./.github/actions/cache-ksail-binary | |
| with: | |
| go-version: ${{ steps.setup-go.outputs.go-version }} | |
| source-hash: ${{ hashFiles('go.mod', 'go.sum', '**/*.go') }} | |
| output-path: /usr/local/bin/ksail | |
| save: "false" | |
| - name: 🔥 Warm Helm chart cache | |
| id: warm | |
| uses: ./.github/actions/warm-helm-cache | |
| warm-mirror-cache: | |
| name: 🔥 Warm Mirror Cache | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| needs: [changes, license-check, build-artifact, wait-for-validate-go] | |
| if: >- | |
| !cancelled() | |
| && (needs.wait-for-validate-go.result == 'success' || needs.wait-for-validate-go.result == 'skipped') | |
| && ( | |
| (github.event_name == 'pull_request' | |
| && needs.changes.outputs.system-test == 'true' | |
| && github.event.pull_request.head.repo.full_name == github.repository) | |
| || (github.event_name == 'workflow_dispatch' && inputs.run_system_tests == true) | |
| ) | |
| permissions: | |
| contents: read | |
| outputs: | |
| cache-key: ${{ steps.warm.outputs.cache-key }} | |
| steps: | |
| - name: 📄 Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| persist-credentials: false | |
| - name: ⚙️ Setup Go | |
| id: setup-go | |
| uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 | |
| with: | |
| go-version-file: go.mod | |
| - name: 📦 Cache KSail Binary | |
| uses: ./.github/actions/cache-ksail-binary | |
| with: | |
| go-version: ${{ steps.setup-go.outputs.go-version }} | |
| source-hash: ${{ hashFiles('go.mod', 'go.sum', '**/*.go') }} | |
| output-path: /usr/local/bin/ksail | |
| save: "false" | |
| - name: 🔥 Warm mirror cache | |
| id: warm | |
| uses: ./.github/actions/warm-mirror-cache | |
| # Docker-based system tests run in full parallelism with no concurrency | |
| # constraints — each job gets its own runner with local Docker containers. | |
| system-test-docker: | |
| name: 🧪 System Test (Docker) | |
| runs-on: ubuntu-latest | |
| # The Talos host + full nested Kubernetes-provider coverage (Vanilla,K3s,VCluster,Talos) | |
| # has a ~77-min worst case. 120 gives comfortable headroom to absorb runtime variance | |
| # without trimming test coverage. | |
| timeout-minutes: 120 | |
| needs: | |
| [ | |
| changes, | |
| license-check, | |
| build-artifact, | |
| warm-helm-cache, | |
| warm-mirror-cache, | |
| wait-for-validate-go, | |
| ] | |
| if: >- | |
| !cancelled() | |
| && (needs.wait-for-validate-go.result == 'success' || needs.wait-for-validate-go.result == 'skipped') | |
| && ( | |
| (github.event_name == 'pull_request' | |
| && needs.changes.outputs.system-test == 'true' | |
| && github.event.pull_request.head.repo.full_name == github.repository) | |
| || (github.event_name == 'workflow_dispatch' && inputs.run_system_tests == true) | |
| ) | |
| permissions: | |
| contents: read | |
| packages: write | |
| strategy: | |
| # fail-fast: false so a single failing leg does not cancel its siblings. This surfaces | |
| # every failing distribution/arg combination in one run (aligning with the goal of | |
| # making all CI errors visible and blocking) instead of masking later legs behind the | |
| # first failure, and it lets the diagnostic-bearing legs run to completion. | |
| fail-fast: false | |
| max-parallel: 15 | |
| matrix: | |
| distribution: [KWOK, Vanilla, K3s, VCluster, Talos] | |
| provider: [Docker] | |
| init: [true] | |
| args: | |
| - "" | |
| - "--name system-test-cluster --cni Cilium --csi Enabled --load-balancer Enabled --metrics-server Enabled --policy-engine Kyverno --cert-manager Enabled --gitops-engine Flux" | |
| - "--cni Calico --csi Disabled --load-balancer Disabled --metrics-server Disabled --policy-engine Gatekeeper --gitops-engine ArgoCD" | |
| - "--gitops-engine Flux --local-registry ghcr.io/devantler-tech/ksail/system-test-manifests" | |
| - "--gitops-engine ArgoCD --local-registry ghcr.io/devantler-tech/ksail/system-test-manifests" | |
| # init=false (CLI-flag-only path without ksail.yaml) is now covered by | |
| # unit tests (TestCreate_NoConfigFile_*) instead of full E2E clusters. | |
| include: | |
| - distribution: Talos | |
| provider: Docker | |
| init: true | |
| args: "--name system-test-cluster-with-image-verification --image-verification Enabled" | |
| # Enable Kubernetes provider tests on the default (init=true, args="") | |
| # entry for each distribution. This tests all 4 nested distributions | |
| # (Vanilla,K3s,VCluster,Talos) on each host type, once per matrix. | |
| - distribution: Vanilla | |
| provider: Docker | |
| init: true | |
| args: "" | |
| test-kubernetes-provider: "true" | |
| - distribution: K3s | |
| provider: Docker | |
| init: true | |
| args: "" | |
| test-kubernetes-provider: "true" | |
| - distribution: Talos | |
| provider: Docker | |
| init: true | |
| args: "" | |
| test-kubernetes-provider: "true" | |
| - distribution: VCluster | |
| provider: Docker | |
| init: true | |
| args: "" | |
| test-kubernetes-provider: "true" | |
| # Validate Calico on the Kubernetes (k3k) provider. Calico v3.30+'s CRD | |
| # chart ships MutatingAdmissionPolicy resources that need the v1beta1 | |
| # admissionregistration API enabled on the embedded k3s server via the k3k | |
| # Cluster serverArgs. The default test-kubernetes-provider legs above use | |
| # the distribution-default CNI, so this leg is the only one exercising the | |
| # k3k+Calico serverArgs path. A unique host args value keeps this as a | |
| # standalone matrix combination (not merged into the args="" Vanilla leg). | |
| - distribution: Vanilla | |
| provider: Docker | |
| init: true | |
| args: "--name k3k-calico-host" | |
| test-kubernetes-provider: "true" | |
| kubernetes-provider-distributions: "K3s" | |
| kubernetes-provider-cni: "Calico" | |
| # NOTE: KWOK excluded — simulated nodes cannot run real workloads (DinD pods) | |
| steps: | |
| - name: 📄 Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| persist-credentials: false | |
| - name: 🧹 Free disk space | |
| # Targeted `rm -rf` of large hosted-tooling directories that the system | |
| # tests do not need. Avoids the slow `apt-get remove` path used by | |
| # endersonmenezes/free-disk-space, which previously consumed 3–9 min per | |
| # job (with apt autoremove pulling in update-initramfs / man-db rebuilds). | |
| # Each directory removal is best-effort: missing paths are not an error. | |
| run: | | |
| set -euo pipefail | |
| before=$(df --output=avail -BG / | tail -1 | tr -dc '0-9') | |
| # Largest offenders on ubuntu-latest (sizes from | |
| # https://github.com/actions/runner-images). Order by descending size. | |
| sudo rm -rf \ | |
| /usr/local/lib/android \ | |
| /usr/share/dotnet \ | |
| /opt/ghc \ | |
| /usr/local/share/boost \ | |
| /usr/share/swift \ | |
| /opt/hostedtoolcache/CodeQL \ | |
| /opt/hostedtoolcache/PyPy \ | |
| /opt/hostedtoolcache/Ruby \ | |
| /opt/hostedtoolcache/Python \ | |
| /usr/local/share/chromium \ | |
| /usr/local/share/powershell \ | |
| /usr/local/julia* \ | |
| /usr/local/aws-cli \ | |
| /usr/local/aws-sam-cli \ | |
| /usr/share/gradle* \ | |
| /usr/share/az* \ | |
| /usr/share/miniconda || true | |
| docker system prune -af --volumes || true | |
| after=$(df --output=avail -BG / | tail -1 | tr -dc '0-9') | |
| echo "Free disk space on /: ${before}G -> ${after}G (gained $((after - before))G)" | |
| - name: 🔐 Login to Docker Hub | |
| if: ${{ vars.DOCKERHUB_USERNAME != '' }} | |
| uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 | |
| with: | |
| username: ${{ vars.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} # zizmor: ignore[secrets-outside-env] - third-party action input, no environment scoping available | |
| - name: ⚙️ Setup Go | |
| id: setup-go | |
| uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 | |
| with: | |
| go-version-file: go.mod | |
| cache: false | |
| - name: 📦 Cache KSail Binary | |
| uses: ./.github/actions/cache-ksail-binary | |
| with: | |
| go-version: ${{ steps.setup-go.outputs.go-version }} | |
| source-hash: ${{ hashFiles('go.mod', 'go.sum', '**/*.go') }} | |
| output-path: /usr/local/bin/ksail | |
| save: "false" | |
| - name: 📥 Restore Helm Cache | |
| uses: ./.github/actions/restore-helm-cache | |
| - name: 📥 Restore Mirror Cache | |
| uses: ./.github/actions/restore-mirror-cache | |
| - name: 🧪 Run KSail System Test | |
| uses: ./.github/actions/ksail-system-test | |
| with: | |
| distribution: ${{ matrix.distribution }} | |
| provider: ${{ matrix.provider }} | |
| init: ${{ matrix.init }} | |
| args: ${{ matrix.args }} | |
| apply-overlay-path: ".github/fixtures/podinfo-overlay" | |
| test-kubernetes-provider: ${{ matrix.test-kubernetes-provider || 'false' }} | |
| kubernetes-provider-distributions: ${{ matrix.kubernetes-provider-distributions || 'Vanilla,K3s,VCluster,Talos' }} | |
| kubernetes-provider-cni: ${{ matrix.kubernetes-provider-cni || '' }} | |
| ghcr-user: ${{ github.actor }} | |
| ghcr-token: ${{ secrets.GITHUB_TOKEN }} | |
| dockerhub-user: ${{ vars.DOCKERHUB_USERNAME }} | |
| dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} | |
| benchmark: | |
| name: 📊 Benchmark | |
| needs: [changes] | |
| if: >- | |
| (github.event_name == 'push' || github.event_name == 'pull_request') | |
| && needs.changes.outputs.benchmark == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 40 | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| outputs: | |
| skip: ${{ steps.discover.outputs.skip }} | |
| steps: | |
| - name: 📄 Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| persist-credentials: false | |
| - name: ⚙️ Setup Go | |
| uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 | |
| with: | |
| go-version-file: go.mod | |
| - name: 🔍 Discover benchmark packages | |
| id: discover | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| pkgs=$(grep -rl '^func Benchmark' --include='*_test.go' . \ | |
| | xargs -I{} dirname {} \ | |
| | sort -u || true) | |
| if [ -z "$pkgs" ]; then | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| else | |
| { | |
| echo "skip=false" | |
| echo "packages<<EOF" | |
| echo "$pkgs" | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: 🏃 Run benchmarks | |
| if: steps.discover.outputs.skip == 'false' | |
| shell: bash | |
| env: | |
| PACKAGES: ${{ steps.discover.outputs.packages }} | |
| # Reduce -count on PRs (-count=3) to halve benchmark wall time while | |
| # keeping enough samples for the awk averaging step in | |
| # "Prepare benchmark regression gate input" to produce a stable signal. | |
| # On push to main (which feeds benchmark-store / the historical chart), | |
| # keep -count=5 for higher-quality baselines. | |
| BENCH_COUNT: ${{ github.event_name == 'push' && '5' || '3' }} | |
| run: | | |
| set -euo pipefail | |
| echo "$PACKAGES" \ | |
| | xargs go test -bench=. -benchmem -run='^$' -count="${BENCH_COUNT}" -timeout=30m \ | |
| | tee "$RUNNER_TEMP/bench.txt" | |
| - name: 🔍 Prepare benchmark regression gate input | |
| if: steps.discover.outputs.skip == 'false' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| # Filter excluded benchmarks and average -count=N duplicate samples into | |
| # a single representative value per benchmark. | |
| # | |
| # Excluded: BenchmarkCreateTarball_* — I/O-bound; timing is dominated by | |
| # CI runner disk-cache state and can vary 4-5x between runs (see #4090). | |
| # Excluded: benchmarks with ns/op < 100 — too fast for reliable measurement | |
| # on shared runners with CPU clock jitter (see #3698). | |
| # | |
| # Averaging: go test -count=N emits N lines per benchmark. Without | |
| # averaging, github-action-benchmark would compare each of the N lines | |
| # individually against the single stored baseline, generating N alerts | |
| # instead of one and skewing the history chart. Averaging collapses them | |
| # to a single geometric-mean-like value for a clean 1:1 comparison. | |
| # | |
| # The original bench.txt is preserved for artifact upload and historical | |
| # storage. | |
| awk ' | |
| { lines[NR] = $0 } | |
| /^Benchmark/ && /ns\/op/ { | |
| if ($1 ~ /^BenchmarkCreateTarball_/) next | |
| for (i = 1; i <= NF; i++) { | |
| if ($(i+1) == "ns/op" && $i + 0 < 100) next | |
| } | |
| name = $1; count[name]++; iters[name] += $2 | |
| for (i = 3; i <= NF; i++) { | |
| if ($i == "ns/op") ns[name] += $(i-1) | |
| if ($i == "B/op") bop[name] += $(i-1) | |
| if ($i == "allocs/op") alloc[name] += $(i-1) | |
| } | |
| } | |
| END { | |
| for (i = 1; i <= NR; i++) { | |
| line = lines[i] | |
| if (line !~ /^Benchmark/ || line !~ /ns\/op/) { print line; continue } | |
| split(line, f) | |
| nm = f[1] | |
| if (nm ~ /^BenchmarkCreateTarball_/) continue | |
| skip = 0 | |
| for (j = 1; j < length(f); j++) { | |
| if (f[j+1] == "ns/op" && f[j] + 0 < 100) { skip = 1; break } | |
| } | |
| if (skip) continue | |
| if (!emitted[nm]) { | |
| emitted[nm] = 1; n = count[nm] | |
| out = nm "\t" int(iters[nm]/n) "\t" int(ns[nm]/n) " ns/op" | |
| if (nm in bop) out = out " " int(bop[nm]/n) " B/op" | |
| if (nm in alloc) out = out " " int(alloc[nm]/n) " allocs/op" | |
| print out | |
| } | |
| } | |
| } | |
| ' "$RUNNER_TEMP/bench.txt" > "$RUNNER_TEMP/bench-filtered.txt" | |
| - name: 📊 Compare benchmark results | |
| if: steps.discover.outputs.skip == 'false' | |
| uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1 | |
| with: | |
| tool: go | |
| output-file-path: ${{ runner.temp }}/bench-filtered.txt | |
| gh-pages-branch: benchmark-data | |
| benchmark-data-dir-path: dev/bench | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| auto-push: false | |
| alert-threshold: "150%" | |
| fail-threshold: ${{ github.event_name != 'pull_request' && '200%' || '' }} | |
| fail-on-alert: false | |
| comment-on-alert: ${{ github.event_name == 'pull_request' }} | |
| summary-always: true | |
| - name: 📤 Upload benchmark results | |
| if: steps.discover.outputs.skip == 'false' && github.event_name == 'push' | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: bench-results | |
| path: | | |
| ${{ runner.temp }}/bench.txt | |
| ${{ runner.temp }}/bench-filtered.txt | |
| retention-days: 1 | |
| benchmark-store: | |
| name: 📤 Store Benchmark Data | |
| needs: [changes, benchmark] | |
| if: >- | |
| github.event_name == 'push' | |
| && needs.changes.outputs.benchmark == 'true' | |
| && needs.benchmark.outputs.skip == 'false' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: 📄 Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| persist-credentials: false | |
| - name: 📥 Download benchmark results | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: bench-results | |
| path: ${{ runner.temp }} | |
| - name: 📊 Store benchmark data | |
| uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1 | |
| with: | |
| tool: go | |
| output-file-path: ${{ runner.temp }}/bench-filtered.txt | |
| gh-pages-branch: benchmark-data | |
| benchmark-data-dir-path: dev/bench | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| auto-push: true | |
| fail-on-alert: false | |
| comment-on-alert: false | |
| summary-always: false | |
| verify-desktop-tidy: | |
| name: 🧩 Verify Desktop Module Tidy | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| needs: [changes] | |
| if: github.event_name != 'merge_group' && needs.changes.outputs.desktop == 'true' | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: 📄 Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| persist-credentials: false | |
| repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }} | |
| ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} | |
| - name: ⚙️ Setup Go | |
| uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 | |
| with: | |
| go-version-file: go.mod | |
| cache: false | |
| # The desktop app is a SEPARATE Go module that vendors the main module via `replace => ../`, so | |
| # its go.{mod,sum} must independently track the main module's dependency graph. A root dependency | |
| # bump (e.g. a dependabot gomod PR) touches only the root go.{mod,sum} and leaves desktop stale, | |
| # which would break the release-time desktop build with a cryptic "updates to go.mod needed". | |
| # Rather than hard-fail and force a manual `go -C desktop mod tidy`, self-heal the same way | |
| # generated files are synced: run tidy, capture the diff as a patch artifact, and let the | |
| # 📤 Auto-Commit Generated Changes job apply it to the PR branch. Fork PRs — which that job cannot | |
| # push to — still hard-fail with actionable guidance. | |
| - name: 🧹 Tidy desktop module | |
| working-directory: desktop | |
| run: go mod tidy | |
| - name: 📤 Upload patch | |
| run: | | |
| git add -N . | |
| git diff > /tmp/desktop-tidy.patch | |
| if [ -s /tmp/desktop-tidy.patch ]; then | |
| echo "Desktop module drift detected; patch will be auto-committed on same-repo PRs." | |
| else | |
| echo "Desktop module already tidy." | |
| fi | |
| - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: desktop-tidy-patch | |
| path: /tmp/desktop-tidy.patch | |
| if-no-files-found: ignore | |
| retention-days: 1 | |
| - name: ❌ Fail if desktop module is out of date (fork PR) | |
| if: >- | |
| github.event_name == 'pull_request' | |
| && github.event.pull_request.head.repo.full_name != github.repository | |
| shell: bash | |
| run: | | |
| if [ -s /tmp/desktop-tidy.patch ]; then | |
| echo "::error::desktop/go.{mod,sum} are out of date. Please run 'go -C desktop mod tidy' locally and commit the result." | |
| echo "" | |
| cat /tmp/desktop-tidy.patch | |
| exit 1 | |
| fi | |
| require-checks-in-pr: | |
| name: CI - Required Checks | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| needs: | |
| [ | |
| rate-limit-gate, | |
| changes, | |
| ci-go, | |
| wait-for-validate-go, | |
| build-artifact, | |
| generate, | |
| auto-commit, | |
| warm-helm-cache, | |
| warm-mirror-cache, | |
| system-test-docker, | |
| license-check, | |
| home-isolation, | |
| audit-docs, | |
| audit-vsce, | |
| build-docs, | |
| vscode-extension, | |
| copilot-plugin, | |
| benchmark, | |
| operator-chart-lint, | |
| operator-chart-e2e, | |
| verify-desktop-tidy, | |
| ] | |
| if: ${{ always() }} | |
| steps: | |
| - uses: devantler-tech/actions/aggregate-job-checks@0e1232924bf8b07a40b1b24e13e200744fbabcfa # v6.0.0 | |
| with: | |
| job-results: >- | |
| ${{ needs.rate-limit-gate.result }} | |
| ${{ needs.changes.result }} | |
| ${{ needs.ci-go.result }} | |
| ${{ needs.wait-for-validate-go.result }} | |
| ${{ needs.build-artifact.result }} | |
| ${{ needs.generate.result }} | |
| ${{ needs.auto-commit.result }} | |
| ${{ needs.warm-helm-cache.result }} | |
| ${{ needs.warm-mirror-cache.result }} | |
| ${{ needs.system-test-docker.result }} | |
| ${{ needs.license-check.result }} | |
| ${{ needs.home-isolation.result }} | |
| ${{ needs.audit-docs.result }} | |
| ${{ needs.audit-vsce.result }} | |
| ${{ needs.build-docs.result }} | |
| ${{ needs.vscode-extension.result }} | |
| ${{ needs.copilot-plugin.result }} | |
| ${{ needs.benchmark.result }} | |
| ${{ needs.operator-chart-lint.result }} | |
| ${{ needs.operator-chart-e2e.result }} | |
| ${{ needs.verify-desktop-tidy.result }} |