diff --git a/.github/workflows/build-push.yaml b/.github/workflows/build-push.yaml index 0619437..10eb976 100644 --- a/.github/workflows/build-push.yaml +++ b/.github/workflows/build-push.yaml @@ -40,6 +40,11 @@ on: type: number default: 30 description: "Bounded timeout for each native image build; use 5 through 120 minutes" + reclaim-runner-disk: + required: false + type: boolean + default: false + description: "Remove unused preinstalled SDKs from each ephemeral hosted runner before an unusually large build" runners: required: false type: string @@ -231,6 +236,51 @@ jobs: fi } >> "$GITHUB_OUTPUT" + - name: Reclaim hosted runner disk space + if: inputs.reclaim-runner-disk + timeout-minutes: 10 + shell: bash + run: | + set -euo pipefail + df -h / + sudo apt-get remove -y \ + '^aspnetcore-.*' \ + '^dotnet-.*' \ + '^llvm-.*' \ + 'php.*' \ + '^mongodb-.*' \ + '^mysql-.*' \ + azure-cli \ + firefox \ + google-chrome-stable \ + google-cloud-cli \ + google-cloud-sdk \ + microsoft-edge-stable \ + mono-devel \ + powershell \ + snapd \ + --fix-missing 2>/dev/null || true + sudo apt-get autoremove -y 2>/dev/null || true + sudo apt-get clean 2>/dev/null || true + sudo docker image prune --all --force 2>/dev/null || true + sudo rm -rf \ + /opt/google/chrome \ + /opt/hostedtoolcache \ + /opt/microsoft/msedge \ + /opt/microsoft/powershell \ + /opt/pipx \ + /usr/lib/mono \ + /usr/local/julia* \ + /usr/local/lib/android \ + /usr/local/lib/node_modules \ + /usr/local/share/chromium \ + /usr/local/share/powershell \ + /usr/share/dotnet \ + /usr/share/swift \ + 2>/dev/null || true + ghcup nuke 2>/dev/null || true + df -h / + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 with: ref: ${{ inputs.ref }} diff --git a/README.md b/README.md index ecc7aa5..3d99ac7 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,8 @@ An always-run cleanup job retains all run-scoped staging tags when a build or me Builds always run on the fixed `ubuntu-24.04` and `ubuntu-24.04-arm` GitHub-hosted runners, and the merge always consumes the fixed `amd64` and `arm64` platforms. The deprecated `runners` input remains for caller compatibility, but only its default pair is accepted; it cannot select a self-hosted or arbitrary runner with publisher credentials. +Set `reclaim-runner-disk: true` only for source builds whose compiler and BuildKit working sets exhaust the default hosted-runner disk. The opt-in step removes unused preinstalled SDKs and tool caches from each ephemeral runner before checkout; later setup actions must therefore install any required host tools again. It does not change the fixed runner pair, registry credentials, scan gate, or publication policy. + ### Caller secrets The reusable workflow declares four optional secrets so callers can pass only what their registry needs. GHCR uses the caller's automatic `GITHUB_TOKEN` and needs none of these secrets. GAR callers explicitly map `GCLOUD_OIDC_POOL` and `GSA`; Docker Hub callers explicitly map `DOCKERHUB_USER` and `DOCKERHUB_PASSWORD`. Do not use `secrets: inherit`. diff --git a/ci/github/build_push_contract_test.go b/ci/github/build_push_contract_test.go index 5bbb308..4babf7d 100644 --- a/ci/github/build_push_contract_test.go +++ b/ci/github/build_push_contract_test.go @@ -148,6 +148,53 @@ func TestNativeBuildTimeoutIsBoundedCallerInput(t *testing.T) { requireContains(t, validation, "BUILD_TIMEOUT_MINUTES < 5 || BUILD_TIMEOUT_MINUTES > 120") } +func TestOptionalRunnerDiskReclamationIsExplicitAndScoped(t *testing.T) { + workflow := workflowSource(t) + inputs := stepBlock(t, workflow, " inputs:\n", " secrets:\n") + reclaim := stepBlock(t, workflow, " - name: Reclaim hosted runner disk space\n", " - uses: actions/checkout@") + + for _, required := range []string{ + "reclaim-runner-disk:\n", + "type: boolean", + "default: false", + } { + requireContains(t, inputs, required) + } + for _, required := range []string{ + "if: inputs.reclaim-runner-disk", + "timeout-minutes: 10", + "set -euo pipefail", + "sudo apt-get remove -y", + "sudo apt-get autoremove -y", + "sudo apt-get clean", + "sudo docker image prune --all --force", + "/opt/hostedtoolcache", + "/usr/local/lib/android", + "/usr/local/lib/node_modules", + "/usr/share/dotnet", + "/usr/share/swift", + "ghcup nuke", + "df -h /", + } { + requireContains(t, reclaim, required) + } + if got := strings.Count(workflow, "if: inputs.reclaim-runner-disk"); got != 1 { + t.Fatalf("runner-disk reclamation guard appears %d times, want exactly one native-build guard", got) + } + if strings.Contains(reclaim, "uses:") { + t.Error("runner-disk reclamation must not delegate host deletion to a third-party action") + } + if strings.Contains(reclaim, "$AGENT_TOOLSDIRECTORY") { + t.Error("runner-disk reclamation paths must be a fixed allowlist") + } + validationIndex := strings.Index(workflow, " - name: validate input\n") + reclaimIndex := strings.Index(workflow, " - name: Reclaim hosted runner disk space\n") + checkoutIndex := strings.Index(workflow, " - uses: actions/checkout@") + if validationIndex == -1 || reclaimIndex == -1 || checkoutIndex == -1 || !(validationIndex < reclaimIndex && reclaimIndex < checkoutIndex) { + t.Fatal("runner-disk reclamation must run after validation and before checkout") + } +} + func TestDefaultImageNameReceivesCanonicalValidation(t *testing.T) { workflow := workflowSource(t) validation := stepBlock(t, workflow, " - name: validate input\n", " - uses: actions/checkout@")