fix(docs-version-sync): use the .git marker for the repo root #1758
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
| # SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. | |
| # SPDX-License-Identifier: Apache-2.0 | |
| # | |
| # Bazel build + test on the public NVIDIA/nvcf mirror. | |
| # | |
| # The detect job computes dependency-aware subtree selection, including shared | |
| # Java triggers and framework-to-service reverse dependencies. A change is | |
| # scheduled only for the subtrees it touches: each subtree is an isolated Bazel | |
| # module, so a change under one service cannot break another. workflow_dispatch, | |
| # a change to this workflow, and unsafe/unknown diffs (unreadable or rewritten | |
| # history, a new branch) fall back to the full matrix so an untrustworthy | |
| # changeset is never under-tested. | |
| # | |
| # Matrix entries reference subtrees that have their Bazel scaffold | |
| # mirrored to GitHub (each subtree's upstream .oss-allowlist controls | |
| # whether BUILD.bazel files reach this mirror). Add a static ROWS entry when a | |
| # non-Java subtree's OSS-flip MR merges upstream. Java components are | |
| # discovered from component-local bazel-java-ci.json files. | |
| # | |
| # Image push targets and internal NGC registry pulls are internal-only; this | |
| # workflow runs build + test only, using the public EC2 Buildbarn remote cache | |
| # reachable from GitHub-hosted runners. The bazel-ci image is built by the | |
| # internal nvcf/bazel-ci-templates project and mirrored to GHCR for this | |
| # public workflow. | |
| name: bazel | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| merge_group: | |
| types: [checks_requested] | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| packages: read | |
| # Force bash for every `run:` step. GitHub Actions picks the step shell | |
| # per host: on the bare ubuntu-latest runner (the detect job) it is | |
| # bash, but inside the bazel-ci container the default resolves to | |
| # /bin/sh (dash), which does not support the `FLAGS=(...)` bash arrays | |
| # the cache steps build. Without this the cache-enabled build/test steps | |
| # die with `Syntax error: "(" unexpected` on every matrix row. | |
| defaults: | |
| run: | |
| shell: bash | |
| concurrency: | |
| group: bazel-${{ github.ref }} | |
| cancel-in-progress: true | |
| # The bazel-ci job container comes from the repository variable | |
| # BAZEL_CI_IMAGE, so the image is bumped in one place (repo settings) instead | |
| # of being edited in every workflow that runs in it. | |
| # | |
| # It must be `vars`, NOT `env`. GitHub Actions evaluates job-level | |
| # `container.image` before the workflow-level `env:` context is reliably | |
| # available, so `${{ env.BAZEL_CI_IMAGE }}` made the matrix expand to zero jobs | |
| # on push events (validated empirically; PR-event runs happened to work). The | |
| # `vars` context does not have that ordering problem. | |
| # | |
| # The `||` fallback keeps CI working if the variable is unset or unavailable | |
| # (for example on a fork). Keep the fallback in step with the variable; it is a | |
| # safety net, not the source of truth. | |
| jobs: | |
| detect: | |
| name: detect changed subtrees | |
| runs-on: ubuntu-latest | |
| outputs: | |
| matrix: ${{ steps.detect.outputs.matrix }} | |
| matrix_docker: ${{ steps.detect.outputs.matrix_docker }} | |
| any: ${{ steps.detect.outputs.any }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Check Java import boundaries | |
| run: bash tools/ci/check-java-import-boundaries | |
| # Behavioral tests for the CI shell this workflow depends on. actionlint | |
| # covers syntax; these cover the decisions. | |
| - name: Test CI shell helpers | |
| run: | | |
| bash tools/ci/test-bazel-cache-upload-mode | |
| bash tools/ci/test-bazel-remote-probe | |
| - name: Compute changed subtrees | |
| id: detect | |
| env: | |
| EVENT: ${{ github.event_name }} | |
| BEFORE_SHA: ${{ github.event.before }} | |
| # merge_group has no `before`; its payload carries the queue base. | |
| MERGE_BASE_SHA: ${{ github.event.merge_group.base_sha }} | |
| HEAD_SHA: ${{ github.sha }} | |
| BASE_REF: ${{ github.base_ref }} | |
| run: | | |
| set -euo pipefail | |
| # id|path|tests_skip. tests_skip notes: | |
| # root: tests run. The few go-lib targets that fail in CI (a stale | |
| # Gazelle BUILD check and the icms-translate tests) are quarantined | |
| # per-target via .github/bazel-root-test-quarantine.txt rather than | |
| # skipping the whole row; nvcf-go owns the fix (NVIDIA/nvcf#284). | |
| # nvca: tests run. The failures tracked in NVIDIA/nvcf#238 were all | |
| # test-layer and are fixed: the vendored dra-driver version stamp | |
| # (x_defs on vendor/.../internal/info, mirroring the module | |
| # Makefile's -ldflags -X), SPDX license headers in testdata and | |
| # rendered template assets (empty-YAML-doc skip + stripSPDXHeaders | |
| # in golden comparisons), and the version-adaptive completeJob for | |
| # envtest Job completion. | |
| # nvsnap (src/compute-plane-services/nvsnap): a root-module row. | |
| # Previously excluded on the grounds that its OCI base was a | |
| # private image; that is no longer true, its bases are public | |
| # and digest-pinned. It builds from the repository root scoped | |
| # to its own subtree, the same shape as the Java components. | |
| # Without a row, a change touching only nvsnap selects no row | |
| # and runs no Bazel, which is how its build stayed broken for | |
| # twelve days. | |
| # Row fields: | |
| # id|path|tests_skip|component_kind|ci_lane | |
| # Static non-Java rows use only the first three fields. | |
| # Order matters: rows are dispatched top-down and max-parallel caps | |
| # how many run at once, so a long row placed late waits for a slot | |
| # before it even starts. byoo-otel-collector is the longest row (its | |
| # collector genrule dominates the build), so it leads. Keep the | |
| # longest-first ordering when adding rows. | |
| ROWS='byoo-otel-collector|src/compute-plane-services/byoo-otel-collector|false | |
| root|.|false | |
| grpc-proxy|src/invocation-plane-services/grpc-proxy|false|go-root|docker-host | |
| nats-auth-callout|src/control-plane-services/nats-auth-callout|false|go-root|build-container | |
| ratelimiter|src/invocation-plane-services/ratelimiter|false|go-root|build-container | |
| http-invocation|src/invocation-plane-services/http-invocation|false|go-root|build-container | |
| llm-api-gateway|src/invocation-plane-services/llm-api-gateway|false|go-root|build-container | |
| vanity-gateway|src/invocation-plane-services/vanity-gateway|false|go-root|build-container | |
| nvca|src/compute-plane-services/nvca|false|go-root|build-container | |
| ess-agent|src/compute-plane-services/ess-agent|false|go-root|build-container | |
| nvcf-unbound|src/compute-plane-services/nvcf-unbound|false|go-root|build-container | |
| worker-init|src/compute-plane-services/worker-init|false|go-root|build-container | |
| worker-llm-credentials|src/compute-plane-services/worker-llm-credentials|false|go-root|build-container | |
| worker-task|src/compute-plane-services/worker-task|false|go-root|build-container | |
| worker-utils|src/compute-plane-services/worker-utils|false|go-root|build-container | |
| function-autoscaler|src/control-plane-services/function-autoscaler|false|go-root|build-container | |
| helm-reval|src/control-plane-services/helm-reval|false|go-root|build-container | |
| stargate|src/libraries/rust/stargate|false|go-root|build-container | |
| nvsnap|src/compute-plane-services/nvsnap|false|go-root|build-container | |
| image-credential-helper|src/compute-plane-services/image-credential-helper|false|go-root|build-container' | |
| # Java rows are discovered from component-local bazel-java-ci.json | |
| # descriptors. The descriptor's parent directory is the component | |
| # path, so adding a Java service never requires editing this workflow. | |
| # jq validates every field before the row reaches matrix generation. | |
| while IFS= read -r manifest; do | |
| path="${manifest%/bazel-java-ci.json}" | |
| for forbidden in \ | |
| MODULE.bazel MODULE.bazel.lock maven_install.json \ | |
| .bazelrc .bazelversion .bazel_downloader_config \ | |
| WORKSPACE WORKSPACE.bazel bazel-enablement bazel-migration; do | |
| if [ -e "${path}/${forbidden}" ]; then | |
| echo "::error file=${path}/${forbidden}::Java components use root Bazel state; remove imported standalone file" | |
| exit 1 | |
| fi | |
| done | |
| id="$(jq -er '.id | select(test("^[a-z0-9][a-z0-9-]*$"))' "$manifest")" | |
| tests_skip="$(jq -er '.tests_skip | if type == "boolean" then tostring else error("tests_skip must be boolean") end' "$manifest")" | |
| component_kind="$(jq -er '.component_kind | select(. == "java-framework" or . == "java-service")' "$manifest")" | |
| ci_lane="$(jq -er '.ci_lane | select(. == "build-container" or . == "docker-host")' "$manifest")" | |
| ROWS="${ROWS} | |
| ${id}|${path}|${tests_skip}|${component_kind}|${ci_lane}" | |
| done < <(find src -name bazel-java-ci.json -type f -print | LC_ALL=C sort) | |
| # Every discovered Java component belongs to the root `nvcf` Bazel | |
| # module; it does not own a nested MODULE.bazel. Its lane therefore | |
| # invokes Bazel from the repository root and scopes work to | |
| # //<component-path>/... . Descriptor component_kind and ci_lane | |
| # fields derive shared triggers, framework-consumer validation, | |
| # execution-environment routing, and artifact upload; do not maintain | |
| # separate component-name lists for those behaviors. | |
| # Paths that affect the root-module workspace build (native root | |
| # subtrees plus the shared Bazel scaffold). Java and stargate no longer | |
| # ride the root row: discovered Java components and stargate have | |
| # their own scoped rows, so src/libraries/ is narrowed to | |
| # src/libraries/go/. | |
| ROOT_GLOBS=(MODULE.bazel .bazelignore .bazelrc .bazelversion BUILD.bazel WORKSPACE WORKSPACE.bazel go.work go.work.bazel rules/ platforms/ tools/ ci/ src/clis/ src/libraries/go/ .github/bazel-root-test-quarantine.txt) | |
| run_all=false | |
| changed="" | |
| case "$EVENT" in | |
| workflow_dispatch) run_all=true ;; | |
| pull_request) | |
| if ! changed=$(git diff --name-only "origin/${BASE_REF}...HEAD" 2>/dev/null); then | |
| echo "diff vs origin/${BASE_REF} failed; running full matrix" | |
| run_all=true | |
| fi | |
| ;; | |
| merge_group) | |
| # `github.event.before` does not exist on merge_group, so without | |
| # this branch every queue entry fell through to the default arm | |
| # and rebuilt all 24 rows regardless of what the pull request | |
| # touched. The queue head is the base plus the queued commits, so | |
| # diffing from base_sha yields exactly those changes (and, for a | |
| # batched entry, every pull request in the batch). | |
| if [ -z "${MERGE_BASE_SHA}" ]; then | |
| echo "merge_group base_sha missing; running full matrix" | |
| run_all=true | |
| elif ! git rev-parse --verify -q "${MERGE_BASE_SHA}^{commit}" >/dev/null 2>&1; then | |
| echo "merge_group base ${MERGE_BASE_SHA} not in history; running full matrix" | |
| run_all=true | |
| elif ! changed=$(git diff --name-only "${MERGE_BASE_SHA}...${HEAD_SHA}" 2>/dev/null); then | |
| echo "diff ${MERGE_BASE_SHA}...${HEAD_SHA} failed; running full matrix" | |
| run_all=true | |
| fi | |
| ;; | |
| *) | |
| if [ -z "${BEFORE_SHA}" ] || [ "${BEFORE_SHA}" = "0000000000000000000000000000000000000000" ]; then | |
| run_all=true | |
| elif ! changed=$(git diff --name-only "${BEFORE_SHA}..${HEAD_SHA}" 2>/dev/null); then | |
| echo "diff ${BEFORE_SHA}..${HEAD_SHA} failed (rewritten history?); running full matrix" | |
| run_all=true | |
| fi | |
| ;; | |
| esac | |
| # Change-aware scheduling. Each subtree is an isolated Bazel module, so | |
| # a change under src/<service> only affects that service's own //... | |
| # build+test and can never break another module. Select only the | |
| # subtrees a change actually touches (path prefix), plus the | |
| # reverse-dependency edges below (shared Java config -> every Java row; | |
| # a Java framework -> every Java service) and ROOT_GLOBS -> root row. A | |
| # docs-only or helm-only change touches no subtree and runs nothing. | |
| # The full-matrix fallbacks stay in the case above (workflow_dispatch, | |
| # an unreadable or rewritten diff, a new branch) so an untrustworthy | |
| # changeset is never under-tested. | |
| # | |
| # A change to this workflow itself revalidates everything. | |
| if grep -q '^\.github/workflows/bazel\.yml$' <<<"$changed"; then | |
| run_all=true | |
| fi | |
| # Reverse-dependency edge. A discovered Java framework change | |
| # validates every discovered java-service row. | |
| java_framework_changed=false | |
| if [ "$run_all" != "true" ]; then | |
| while IFS='|' read -r _id path _tests_skip component_kind _ci_lane; do | |
| component_kind="$(echo "$component_kind" | xargs)" | |
| if [ "$component_kind" = "java-framework" ] && grep -q "^${path}/" <<<"$changed"; then | |
| java_framework_changed=true | |
| break | |
| fi | |
| done <<< "$ROWS" | |
| fi | |
| # Root-owned Java dependencies and reusable Java rules/tools affect | |
| # every root-scoped Java component even though the changed path is | |
| # outside that component's subtree, so a change to any of them schedules | |
| # every Java row (edge applied below). | |
| JAVA_SHARED_GLOBS=( | |
| MODULE.bazel | |
| MODULE.bazel.lock | |
| maven_install.json | |
| .bazelrc | |
| .bazelversion | |
| .bazel_downloader_config | |
| rules/java/ | |
| tools/bazel/java/ | |
| tools/ci/stage-bazel-java-artifacts | |
| ) | |
| java_shared_changed=false | |
| if [ "$run_all" != "true" ]; then | |
| for g in "${JAVA_SHARED_GLOBS[@]}"; do | |
| if printf '%s\n' "$changed" | awk -v p="$g" 'index($0, p) == 1 { f = 1 } END { exit !f }'; then | |
| java_shared_changed=true | |
| break | |
| fi | |
| done | |
| fi | |
| # Rows with ci_lane=docker-host own requires-docker (Testcontainers) | |
| # tests. They run directly on the GitHub host where a Docker daemon is | |
| # available, executing their FULL suite with no tag filter. Such a row | |
| # is not also placed in the build-container matrix. | |
| # grpc-proxy owns a requires-docker test (proxy/geo:geo_test) and runs in | |
| # this docker-host lane (NVIDIA/nvcf#396). | |
| include="[]" | |
| include_docker="[]" | |
| while IFS='|' read -r id path tests_skip component_kind ci_lane; do | |
| id="$(echo "$id" | xargs)"; [ -z "$id" ] && continue | |
| component_kind="$(echo "$component_kind" | xargs)" | |
| ci_lane="$(echo "$ci_lane" | xargs)" | |
| # Components that belong to the root module build from the | |
| # repository root and scope to their own subtree. That was | |
| # Java-only until nvsnap joined the root module; the discriminator | |
| # is module membership, not language. Nested modules keep their | |
| # module-local behavior. | |
| case "$component_kind" in | |
| java-*|go-root) workdir="."; scope="//${path}/..."; scoped="root" ;; | |
| *) workdir="$path"; scope="//..."; scoped="" ;; | |
| esac | |
| # The root row owns what no other row claims, and nothing more. | |
| # | |
| # While every service had its own nested module those subtrees sat | |
| # in .bazelignore, so `//...` from the repository root simply could | |
| # not see them and the root row naturally covered only root-owned | |
| # code. Consolidation removed those entries, which silently turned | |
| # the root row into a superset of all eighteen service rows: it | |
| # rebuilt and retested every service a second time, on one machine, | |
| # with their tests running concurrently. | |
| # | |
| # That is not merely wasted work. Sixty-eight test packages in this | |
| # repository bind literal ports, and they overlap heavily across | |
| # services (8080 in fifteen packages, 4222 in twelve). Each was safe | |
| # while it had a row to itself. Run together they collide, which is | |
| # how vanity-gateway's suite began panicking with | |
| # "listen tcp 0.0.0.0:10085: bind: address already in use" while its | |
| # own row stayed green. | |
| # | |
| # Subtracting the rows that own a path restores the invariant that | |
| # every target is covered by exactly one row, which removes the | |
| # duplicate work and the cross-service concurrency together. Fixing | |
| # the hard-coded ports is worth doing on its own merits, but it is a | |
| # change to sixty-eight packages and does not belong here. | |
| if [ "$id" = "root" ]; then | |
| excl="" | |
| while IFS='|' read -r _oid opath _ots _ock _ocl; do | |
| opath="$(echo "$opath" | xargs)" | |
| [ -z "$opath" ] || [ "$opath" = "." ] && continue | |
| excl="${excl} -//${opath}/..." | |
| done <<< "$ROWS" | |
| scope="//... ${excl}" | |
| fi | |
| hit=false | |
| if [ "$run_all" = "true" ]; then | |
| hit=true | |
| elif [ "$path" = "." ]; then | |
| # Root is glob-gated on PRs and merges alike: it runs only when a | |
| # ROOT_GLOB file changed (a docs- or helm-only change never triggers | |
| # it). When it does run on a main push it uploads (CACHE_UPLOAD), | |
| # warming the cache; the rings do not evict, so a later root PR reads | |
| # that warm closure. | |
| for g in "${ROOT_GLOBS[@]}"; do | |
| # Fixed-string, start-of-line prefix test via awk index()==1: | |
| # avoids treating the dots in .bazelrc/MODULE.bazel as regex | |
| # wildcards and avoids any shell glob expansion of the token. | |
| if printf '%s\n' "$changed" | awk -v p="$g" 'index($0, p) == 1 { f = 1 } END { exit !f }'; then hit=true; break; fi | |
| done | |
| # Root-level *.bzl files have no fixed path prefix, so the awk | |
| # prefix test above cannot match them. The target-selection logic | |
| # treats them as full-build triggers, so gate root on them | |
| # explicitly; otherwise a root-global .bzl change selects zero rows | |
| # and passes without running Bazel. | |
| if [ "$hit" != "true" ] && grep -qE '^[^/]+\.bzl$' <<<"$changed"; then hit=true; fi | |
| else | |
| if grep -q "^${path}/" <<<"$changed"; then hit=true; fi | |
| fi | |
| # A framework change schedules every registered Java service. | |
| if [ "$hit" != "true" ] && [ "$java_framework_changed" = "true" ] && [ "$component_kind" = "java-service" ]; then | |
| hit=true | |
| fi | |
| # Shared Java configuration, dependency locks, rules, and tools | |
| # schedule every Java row, not merely the root row. | |
| if [ "$hit" != "true" ] && [ "$java_shared_changed" = "true" ]; then | |
| case "$component_kind" in java-*) hit=true ;; esac | |
| fi | |
| if [ "$hit" = "true" ]; then | |
| entry=$(jq -cn --arg id "$id" --arg path "$path" --argjson ts "$tests_skip" --arg wd "$workdir" --arg sc "$scope" --arg sm "$scoped" --arg ck "$component_kind" --arg cl "$ci_lane" '{id:$id, path:$path, tests_skip:$ts, workdir:$wd, scope:$sc, scoped:$sm, component_kind:$ck, ci_lane:$cl}') | |
| case "$ci_lane" in | |
| docker-host) include_docker=$(printf '%s' "$include_docker" | jq -c --argjson e "$entry" '. + [$e]') ;; | |
| *) include=$(printf '%s' "$include" | jq -c --argjson e "$entry" '. + [$e]') ;; | |
| esac | |
| fi | |
| done <<< "$ROWS" | |
| count=$(printf '%s' "$include" | jq 'length') | |
| count_docker=$(printf '%s' "$include_docker" | jq 'length') | |
| total=$((count + count_docker)) | |
| echo "selected ${total} subtree(s): build-container=[$(printf '%s' "$include" | jq -r '[.[].id] | join(", ")')] docker-host=[$(printf '%s' "$include_docker" | jq -r '[.[].id] | join(", ")')]" | |
| echo "matrix=${include}" >> "$GITHUB_OUTPUT" | |
| echo "matrix_docker=${include_docker}" >> "$GITHUB_OUTPUT" | |
| if [ "$total" -gt 0 ]; then echo "any=true" >> "$GITHUB_OUTPUT"; else echo "any=false" >> "$GITHUB_OUTPUT"; fi | |
| bazel: | |
| name: bazel (${{ matrix.subtree.id }}) | |
| needs: detect | |
| # Guard on THIS lane's matrix, not the combined `any`. A change touching | |
| # only docker-host subtrees leaves this matrix empty while `any` is still | |
| # true; GitHub cannot create a job from an empty matrix vector and resolves | |
| # it to `failure`, not `skipped`, which failed the required check on PRs | |
| # that had nothing wrong with them. bazel-docker already guards this way. | |
| if: needs.detect.outputs.matrix != '[]' | |
| # NVIDIA self-hosted runners (nv-cpu-general), not GitHub-hosted. Access is | |
| # granted per repository in nv-gha-runners/enterprise-runner-configuration | |
| # and expires, so it must be renewed there. | |
| # | |
| # This is a capacity change, not a tuning one. On GitHub-hosted runners a | |
| # merge-queue entry saw twelve rows start immediately and the rest wait 26 | |
| # to 31 minutes for a runner, against a slowest row of ~650s: the wall clock | |
| # was queueing, not work. cpu16 is also 4x the cores of the hosted runner, | |
| # so it shortens the critical path as well as the wait, which matters | |
| # because the row is a Bazel build that parallelises well. | |
| # NVIDIA policy blocks `pull_request` events on self-hosted runners, since a | |
| # fork's pull request could otherwise execute untrusted code inside the | |
| # network: the job is accepted and then killed at setup with "Workflows | |
| # triggered by pull_request events are not allowed to run on self-hosted | |
| # runners". Pull requests therefore stay on GitHub-hosted capacity. | |
| # | |
| # merge_group and push run trusted, already-reviewed code, and the merge | |
| # queue is where the starvation actually hurt: twelve rows waited 26 to 31 | |
| # minutes for a runner against a slowest row of ~650s. | |
| runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-latest' || 'linux-amd64-cpu16' }} | |
| # Public EC2 Buildbarn cache (grpcs, bearer-token gated). The token is a | |
| # repo secret, so fork PRs (which never receive secrets) fall back to a | |
| # cacheless build automatically. The CA cert is a repo variable (public). | |
| env: | |
| CACHE_TOKEN: ${{ secrets.BAZEL_REMOTE_CACHE_TOKEN }} | |
| CACHE_ENDPOINT: ${{ vars.BAZEL_REMOTE_CACHE_ENDPOINT }} | |
| container: | |
| # This pinned image is built in the internal nvcf/bazel-ci-templates | |
| # project and mirrored to GHCR. It supplies Temurin 25 at JAVA_HOME; | |
| # Bazelisk then selects the root .bazelversion release. Update this tag | |
| # only after the corresponding internal image has been published and | |
| # mirrored. | |
| image: ${{ vars.BAZEL_CI_IMAGE || 'ghcr.io/nvidia/nvcf/bazel-ci:0.14.0' }} | |
| credentials: | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| strategy: | |
| fail-fast: false | |
| # Cap concurrent rows. A full matrix otherwise starts ~20 jobs at once, | |
| # and every runner pulls actions/checkout from codeload.github.com in the | |
| # same instant, tripping GitHub's action-download rate limit (HTTP 429 in | |
| # "Set up job"). Batching keeps the download burst under that limit; | |
| # change-aware scheduling already keeps most PRs well below the cap. | |
| max-parallel: 8 | |
| matrix: | |
| subtree: ${{ fromJSON(needs.detect.outputs.matrix) }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| # Full history so the root row can diff a PR against its base to | |
| # compute affected targets (see "Determine build targets"). | |
| fetch-depth: 0 | |
| # A subtree can be selected but not yet have its Bazel scaffold on | |
| # the mirror (its upstream OSS-flip MR is still open, or the | |
| # umbrella's imports.yaml pin has not been bumped past that flip). | |
| - name: Skip if subtree has no MODULE.bazel yet | |
| id: precheck | |
| run: | | |
| if [ "${{ matrix.subtree.scoped }}" != "root" ] && [ ! -f "${{ matrix.subtree.path }}/MODULE.bazel" ]; then | |
| echo "no MODULE.bazel at ${{ matrix.subtree.path }} -- skipping" | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "skip=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| # bazelisk is preinstalled in the bazel-ci image as `bazel`. | |
| - name: bazel version | |
| if: steps.precheck.outputs.skip == 'false' | |
| working-directory: ${{ matrix.subtree.workdir }} | |
| run: bazel version | |
| # Cache identity is keyed on the module, not on the row. | |
| # | |
| # These paths hold the Bazel install base and the repository cache. No | |
| # disk_cache is configured, so their content is a pure function of the | |
| # two files hashed below and carries nothing row-specific. Keying on | |
| # matrix.subtree.id made sense while every subtree had its own nested | |
| # MODULE.bazel: the rows genuinely had different dependency closures. | |
| # | |
| # They no longer do. Every row that builds from the root module hashes | |
| # the same root lock and produces byte-identical content, so keying by id | |
| # would store ~22 copies of the same multi-gigabyte entry. GitHub's cache | |
| # is 10 GB per repository with LRU eviction, so those copies cannot | |
| # coexist: they would evict each other on every run and the cache would | |
| # stop working for every branch, not just this one. Root-scoped rows | |
| # therefore share a single identity, which is what their content already | |
| # implies. The literal `root` row lands on the same key by construction, | |
| # since it hashes the same files. | |
| # | |
| # Concurrent saves to one key are safe: the first writer wins and the rest | |
| # no-op. Rows with their own module keep a per-row identity. | |
| - name: Cache Bazel repository + disk caches | |
| if: steps.precheck.outputs.skip == 'false' | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/.cache/bazel/_bazel_${{ env.USER || 'root' }}/install | |
| ~/.cache/bazel/_bazel_${{ env.USER || 'root' }}/cache | |
| key: bazel-${{ matrix.subtree.workdir == '.' && 'rootmodule' || matrix.subtree.id }}-${{ hashFiles(format('{0}/MODULE.bazel.lock', matrix.subtree.workdir), format('{0}/.bazelversion', matrix.subtree.workdir)) }} | |
| restore-keys: | | |
| bazel-${{ matrix.subtree.workdir == '.' && 'rootmodule' || matrix.subtree.id }}- | |
| # byoo-otel-collector's binary is built by a no-sandbox genrule that shells | |
| # out to `go build` (ocb-generated source) and fetches the whole otel | |
| # collector module graph from GOPROXY. That download is outside Bazel's | |
| # dependency graph and the Bazel cache above, so cache the genrule's Go | |
| # module + build cache separately, keyed on the collector go.sum, so cold | |
| # runners restore it instead of re-downloading. See NVIDIA/nvcf#373. | |
| - name: Cache byoo-otel-collector Go module cache | |
| if: matrix.subtree.id == 'byoo-otel-collector' && steps.precheck.outputs.skip == 'false' | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ${{ runner.temp }}/byoo-gomodcache | |
| ${{ runner.temp }}/byoo-gocache | |
| key: byoo-gomod-${{ hashFiles('src/compute-plane-services/byoo-otel-collector/otelcol/go.sum') }} | |
| restore-keys: | | |
| byoo-gomod- | |
| # Decide the remote cache to use. The public EC2 Buildbarn is reachable | |
| # from GitHub-hosted runners over grpcs with a bearer token. When the | |
| # token secret is present | |
| # (same-repo PRs and main pushes; never fork PRs), materialize the | |
| # CA cert and enable the cache; otherwise build cacheless. | |
| # | |
| # Reliability note: the build/test steps use --remote_download_all, | |
| # not --remote_download_minimal (Build without the Bytes). BwtB | |
| # keeps cache-hit outputs remote and fetches them lazily; a brief | |
| # cache blip (e.g. an HTTP 502 during the matrix-start connection | |
| # burst) then becomes a fatal `lost inputs with digests` because | |
| # Bazel can no longer refetch them, and eviction-retries do not | |
| # cover UNAVAILABLE. --remote_download_all materializes every output | |
| # locally, so a transient cache error degrades to a local rebuild | |
| # instead of failing CI. The cost is more egress; correctness on the | |
| # public mirror wins. Revisit toward toplevel/minimal once the cache | |
| # front end absorbs the connection burst without 502s. | |
| - name: Prepare remote cache | |
| if: steps.precheck.outputs.skip == 'false' | |
| run: | | |
| upload=false | |
| if [ -n "$CACHE_TOKEN" ] && [ -n "$CACHE_ENDPOINT" ]; then | |
| printf '%s\n' "${{ vars.BAZEL_REMOTE_CACHE_CA }}" > "$RUNNER_TEMP/cache-ca.pem" | |
| echo "CACHE_READY=1" >> "$GITHUB_ENV" | |
| # Who may write to the shared cache lives in a script so it is | |
| # testable and stated once; see tools/ci/bazel-cache-upload-mode | |
| # and its test for the rationale and the full event matrix. | |
| upload="$(bash "$GITHUB_WORKSPACE/tools/ci/bazel-cache-upload-mode")" | |
| echo "CACHE_UPLOAD=$upload" >> "$GITHUB_ENV" | |
| # Make the mode visible in the log so a missing warm-write is never | |
| # a silent guess: read-only PRs show upload=false, merge-queue runs | |
| # and main pushes true. | |
| if [ "$upload" = "true" ]; then | |
| echo "remote cache ready: read-write (warming; upload=true)" | |
| else | |
| echo "remote cache ready: read-only (upload=false)" | |
| fi | |
| else | |
| echo "CACHE_READY=0" >> "$GITHUB_ENV" | |
| echo "remote cache unavailable (no token/endpoint): cacheless build" | |
| fi | |
| # Write the target patterns to build/test into $RUNNER_TEMP/targets.txt. | |
| # Default is the whole workspace (//...). On a PR, the root row -- which | |
| # builds the full ~7k-target closure -- narrows to just the targets a | |
| # modified Go source affects, so a PR touching a sliver of src/libraries | |
| # does not rebuild everything even on a cold cache. | |
| # | |
| # Safety: narrow ONLY when every change is a modification (status M) to an | |
| # existing .go file. Any add/delete/rename, any global file (MODULE.bazel, | |
| # .bazelrc, *.bzl, go.work, ...), any non-Go file, or any query failure | |
| # falls back to //.... The rdeps query runs WITHOUT --keep_going so an | |
| # unresolvable label forces the full build instead of silently dropping a | |
| # target. This can over-build (safe) but never under-build. | |
| - name: Determine build targets | |
| id: targets | |
| if: steps.precheck.outputs.skip == 'false' | |
| working-directory: ${{ matrix.subtree.workdir }} | |
| env: | |
| EVENT: ${{ github.event_name }} | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| SUBTREE_PATH: ${{ matrix.subtree.path }} | |
| SCOPE: ${{ matrix.subtree.scope }} | |
| run: | | |
| set -uo pipefail | |
| tfile="$RUNNER_TEMP/targets.txt" | |
| # SCOPE is //... for standalone-module rows, and //<path>/... | |
| # for a root-scoped lane so it builds only its own targets out of the | |
| # shared root module. The root row is several patterns in one string | |
| # ("//... -//a/... -//b/..."), because it subtracts every other row's | |
| # path. --target_pattern_file takes one pattern per line, so SCOPE is | |
| # deliberately unquoted here to word-split; quoting it writes the whole | |
| # string as a single line and Bazel rejects it as one invalid package | |
| # name. The patterns contain no glob characters, so splitting is safe. | |
| full() { printf '%s\n' $SCOPE > "$tfile"; echo "targets: $SCOPE (full build)"; } | |
| # Only the root row on a pull_request is a narrowing candidate. Every | |
| # other trigger (main push warms + uploads the whole closure; dispatch | |
| # and schedule are full by intent) and every per-service row build the | |
| # whole workspace. | |
| if [ "$EVENT" != "pull_request" ] || [ "$SUBTREE_PATH" != "." ]; then | |
| full; exit 0 | |
| fi | |
| # Diff against the PR base commit SHA. The base commit is present in | |
| # history because the bazel-job checkout uses fetch-depth: 0. The | |
| # symbolic origin/<branch> ref is not reliably created inside the | |
| # container checkout, so resolve against the SHA directly. | |
| base="$BASE_SHA" | |
| if [ -z "$base" ] || ! git rev-parse --verify "$base^{commit}" >/dev/null 2>&1; then | |
| echo "base sha unavailable; full build"; full; exit 0 | |
| fi | |
| if ! ns=$(git diff --name-status "$base...HEAD" 2>/dev/null); then | |
| echo "diff vs base failed; full build"; full; exit 0 | |
| fi | |
| if [ -z "$ns" ]; then | |
| echo "no changes vs base; nothing to build"; : > "$tfile"; exit 0 | |
| fi | |
| labels=() | |
| while IFS=$'\t' read -r status path _rest; do | |
| [ -z "$status" ] && continue | |
| # Anything other than a plain modification is structural (add, | |
| # delete, rename, copy, type-change) -- fall back to full. | |
| if [ "$status" != "M" ]; then | |
| echo "non-modify change ($status $path); full build"; full; exit 0 | |
| fi | |
| case "$path" in | |
| MODULE.bazel|MODULE.bazel.lock|.bazelrc|.bazelversion|WORKSPACE|WORKSPACE.bazel|go.work|go.work.bazel|*.bzl) | |
| echo "global file changed ($path); full build"; full; exit 0 ;; | |
| *.go) | |
| dir=$(dirname "$path"); bn=$(basename "$path") | |
| if [ "$dir" = "." ]; then labels+=("//:$bn"); else labels+=("//$dir:$bn"); fi ;; | |
| *) | |
| echo "non-Go modification ($path); full build"; full; exit 0 ;; | |
| esac | |
| done <<< "$ns" | |
| if [ "${#labels[@]}" -eq 0 ]; then | |
| echo "no buildable changes; nothing to build"; : > "$tfile"; exit 0 | |
| fi | |
| # Strict (no --keep_going): if any changed-file label does not resolve | |
| # to a known source, the query fails and we fall back to full. | |
| if ! affected=$(bazel query --noshow_progress --output=label \ | |
| "rdeps(//..., set(${labels[*]}))" 2>/dev/null); then | |
| echo "rdeps query failed (unresolved label?); full build"; full; exit 0 | |
| fi | |
| if [ -z "$affected" ]; then | |
| echo "no affected targets; nothing to build"; : > "$tfile"; exit 0 | |
| fi | |
| printf '%s\n' "$affected" > "$tfile" | |
| echo "targets: $(printf '%s\n' "$affected" | grep -c .) affected (narrowed from //...)" | |
| - name: bazel build | |
| if: steps.precheck.outputs.skip == 'false' | |
| working-directory: ${{ matrix.subtree.workdir }} | |
| run: | | |
| if [ ! -s "$RUNNER_TEMP/targets.txt" ]; then | |
| echo "no targets to build; skipping"; exit 0 | |
| fi | |
| # COMMON holds non-cache flags shared by both the cached attempt and | |
| # the cacheless retry, so any future non-cache flag is applied to both. | |
| # CACHE holds only the cache-specific flags. | |
| COMMON=() | |
| if [ "${{ matrix.subtree.id }}" = "byoo-otel-collector" ]; then | |
| # --jobs=1 is a memory guard, not a throughput setting. The | |
| # collector genrule is analysed in three configurations (host, | |
| # //platforms:linux_x86_64, //platforms:linux_arm64) and each | |
| # `go build` of the otelcol module peaks at ~6 GB RSS, so two | |
| # concurrent instances would not fit a 16 GB runner. Bazel has no | |
| # per-action memory throttle for genrules ("resources:memory:N" | |
| # and "cpu:N" execution requirements are ignored outside tests, | |
| # and so is "exclusive"), so capping Bazel-side concurrency is the | |
| # only lever. Per-build parallelism is set inside the genrule | |
| # instead (go build -p $(nproc)), which is ~3x faster on a 4-vCPU | |
| # runner at the same peak RSS. Nothing else in this module is | |
| # expensive (the row's other ~380 actions are cache hits), so the | |
| # cap costs nothing elsewhere. | |
| COMMON+=(--jobs=1) | |
| # Persist the collector genrule's Go module + build cache across CI | |
| # runs so cold runners restore instead of re-downloading the whole | |
| # otel module graph. The genrule is no-sandbox and honours these via | |
| # --action_env; paths sit under RUNNER_TEMP so the byoo cache step can | |
| # save/restore them. Must match on build AND test or Bazel re-runs the | |
| # genrule cacheless. See NVIDIA/nvcf#373. | |
| COMMON+=(--action_env="GOMODCACHE=$RUNNER_TEMP/byoo-gomodcache" | |
| --action_env="GOCACHE=$RUNNER_TEMP/byoo-gocache" | |
| --action_env="GOPATH=$RUNNER_TEMP/byoo-gopath") | |
| # Bind the collector genrule's remote-cache key to the host Go | |
| # toolchain. The genrule shells out to the image's `go`, which is | |
| # not a declared Bazel input, so without this a toolchain bump in | |
| # the bazel-ci image would keep serving collector binaries built by | |
| # the previous compiler -- including across a Go security patch. | |
| # --action_env values are part of the action key, so feeding the | |
| # version string through it turns a toolchain change into a cache | |
| # miss and a rebuild. Must match on build AND test. | |
| byoo_go="$(command -v go || echo /usr/local/go/bin/go)" | |
| byoo_go_ver="$("$byoo_go" version 2>/dev/null || echo unknown)" | |
| echo "byoo collector toolchain: $byoo_go_ver" | |
| COMMON+=(--action_env="BYOO_GO_TOOLCHAIN=$byoo_go_ver") | |
| fi | |
| CACHE=(--remote_cache=) | |
| if [ "${CACHE_READY:-0}" = "1" ]; then | |
| CACHE=(--remote_cache="$CACHE_ENDPOINT" | |
| --tls_certificate="$RUNNER_TEMP/cache-ca.pem" | |
| --remote_header="authorization=Bearer $CACHE_TOKEN" | |
| --remote_cache_compression | |
| --remote_download_all | |
| --remote_timeout=600 --remote_retries=5) | |
| # Force the upload flag explicitly. The root module's .bazelrc pins | |
| # --config=remote, which sets --remote_upload_local_results=false | |
| # (internal read-only dev cache). Without overriding it back to true | |
| # on main pushes, root never warms the public cache, so every root | |
| # build is cold forever. Service modules do not set --config=remote, | |
| # so they are unaffected either way. | |
| if [ "$CACHE_UPLOAD" = "true" ]; then | |
| CACHE+=(--remote_upload_local_results=true) | |
| else | |
| CACHE+=(--remote_upload_local_results=false) | |
| fi | |
| fi | |
| # Rock-solid cache: if the cached build fails for any cache-related | |
| # reason -- the TLS/GetCapabilities handshake, UNAVAILABLE, or a 502 | |
| # during the matrix-start connection burst, none of which | |
| # --remote_download_all or --remote_local_fallback cover -- retry once | |
| # with the cache fully off (same COMMON flags). A real build break | |
| # fails both attempts, so this never hides one. | |
| TARGETS=(--target_pattern_file="$RUNNER_TEMP/targets.txt") | |
| if ! bazel build "${COMMON[@]}" "${CACHE[@]}" "${TARGETS[@]}"; then | |
| [ "${CACHE_READY:-0}" = "1" ] || exit 1 | |
| echo "::warning::remote cache build failed; retrying cacheless (local)" | |
| bazel build "${COMMON[@]}" --remote_cache= "${TARGETS[@]}" | |
| fi | |
| - name: bazel test //... | |
| # `tests_skip: true` on the matrix row keeps the subtree in the matrix | |
| # for build coverage but skips this step. Used when the build passes | |
| # but tests fail with an environment delta from GitLab CI (per the | |
| # documented build-only policy). Default is to run tests. | |
| if: steps.precheck.outputs.skip == 'false' && !matrix.subtree.tests_skip | |
| working-directory: ${{ matrix.subtree.workdir }} | |
| run: | | |
| if [ ! -s "$RUNNER_TEMP/targets.txt" ]; then | |
| echo "no targets to test; skipping"; exit 0 | |
| fi | |
| # Resolve the test targets to run. A full build pattern (//...) filters | |
| # to tests automatically. A narrowed set is specific non-test labels, so | |
| # select the test targets among them with tests(set(...)); a query | |
| # failure falls back to the full test set (correctness over speed), and | |
| # an empty result means the change affects no tests. | |
| ttfile="$RUNNER_TEMP/test-targets.txt" | |
| if grep -qx '//\.\.\.' "$RUNNER_TEMP/targets.txt"; then | |
| cp "$RUNNER_TEMP/targets.txt" "$ttfile" | |
| else | |
| patterns=$(tr '\n' ' ' < "$RUNNER_TEMP/targets.txt") | |
| # Exclude requires-docker tests from the resolved set: they run in the | |
| # Docker-host lane, not here. Naming them explicitly and then | |
| # running `bazel test --test_tag_filters=-requires-docker` fails with | |
| # "No test targets were found, yet testing was requested" (exit 4) when | |
| # a scope's only tests are requires-docker. Filtering | |
| # them in the query lets such a scope resolve to empty and skip cleanly, | |
| # matching the tag filter applied to the run below. | |
| # | |
| # `manual` is excluded for a different reason. Bazel only honours that | |
| # tag when expanding wildcard patterns; naming a target explicitly | |
| # runs it regardless. Because this step resolves an explicit label | |
| # list and passes it to `bazel test`, every manual-tagged test ran in | |
| # CI even though `bazel test //...` skips it locally, so the tag was | |
| # silently doing nothing here. Ten test targets across the repo carry | |
| # it deliberately, with comments saying why. Honouring it restores | |
| # that intent and makes CI agree with a local run. | |
| tests=$(bazel query --noshow_progress --output=label \ | |
| "tests(set($patterns)) except attr(tags, 'requires-docker', set($patterns)) except attr(tags, 'manual', set($patterns))" 2>/dev/null) || tests="__QFAIL__" | |
| if [ "$tests" = "__QFAIL__" ]; then | |
| echo "tests() query failed; falling back to full test set" | |
| printf '//...\n' > "$ttfile" | |
| elif [ -z "$tests" ]; then | |
| # Not a coverage gap: requires-docker tests are excluded here (the | |
| # container has no Docker daemon) and run in the bazel-integration | |
| # lane instead. A scope whose only tests are requires-docker | |
| # resolves empty here and is covered there. | |
| echo "no non-docker test targets in scope; requires-docker tests run in the Docker-host lane"; exit 0 | |
| else | |
| printf '%s\n' "$tests" > "$ttfile" | |
| echo "tests: $(grep -c . "$ttfile") affected test target(s)" | |
| fi | |
| fi | |
| # Root test quarantine: subtract known-flaky packages (nvcf-go owns the | |
| # test-layer fix; see the file header and NVIDIA/nvcf#284) from the root | |
| # row's test set. Negative patterns are appended after the positive | |
| # patterns so Bazel removes them from the resolved set. | |
| # | |
| # The row is matched explicitly. This used to rely on the file simply | |
| # not resolving from a subtree working directory, which stopped being | |
| # true when every migrated service began building from the repository | |
| # root: the quarantine then applied to every root-scoped row. It is | |
| # inert today because the patterns do not intersect those scopes, but | |
| # a future entry that did would silently stop testing a service with | |
| # no signal anywhere. An implicit guard that depends on the working | |
| # directory is the same trap as a `manual` tag that only holds under | |
| # wildcard expansion, so state the intent instead of inferring it. | |
| qfile=".github/bazel-root-test-quarantine.txt" | |
| if [ "${{ matrix.subtree.id }}" = "root" ] && [ -f "$qfile" ]; then | |
| n=$(grep -cE '^[[:space:]]*-//' "$qfile" || true) | |
| grep -E '^[[:space:]]*-//' "$qfile" | sed -E 's/^[[:space:]]+//' >> "$ttfile" | |
| echo "quarantine: subtracted ${n:-0} pattern(s) from the root test set" | |
| fi | |
| # requires-docker tests (Testcontainers/DinD) run in the dedicated | |
| # Docker-host lane, not here; this matrix has no Docker daemon. | |
| # They still compile in the build step; only their execution is | |
| # deferred to that lane (see the bazel-integration job). | |
| COMMON=(--flaky_test_attempts=3 --test_tag_filters=-requires-docker) | |
| if [ "${{ matrix.subtree.id }}" = "byoo-otel-collector" ]; then | |
| # Memory guard, not throughput. See the build step for the full | |
| # rationale (~6 GB peak RSS per collector build, no per-action | |
| # memory throttle for genrules). | |
| COMMON+=(--jobs=1) | |
| # Match the build step's --action_env so the collector genrule reuses | |
| # the cached Go module/build cache instead of re-running (see #373), | |
| # and so the two steps compute the same action key. | |
| COMMON+=(--action_env="GOMODCACHE=$RUNNER_TEMP/byoo-gomodcache" | |
| --action_env="GOCACHE=$RUNNER_TEMP/byoo-gocache" | |
| --action_env="GOPATH=$RUNNER_TEMP/byoo-gopath") | |
| byoo_go="$(command -v go || echo /usr/local/go/bin/go)" | |
| byoo_go_ver="$("$byoo_go" version 2>/dev/null || echo unknown)" | |
| COMMON+=(--action_env="BYOO_GO_TOOLCHAIN=$byoo_go_ver") | |
| fi | |
| CACHE=(--remote_cache=) | |
| if [ "${CACHE_READY:-0}" = "1" ]; then | |
| CACHE=(--remote_cache="$CACHE_ENDPOINT" | |
| --tls_certificate="$RUNNER_TEMP/cache-ca.pem" | |
| --remote_header="authorization=Bearer $CACHE_TOKEN" | |
| --remote_cache_compression | |
| --remote_download_all | |
| --remote_timeout=600 --remote_retries=5) | |
| # Force the upload flag explicitly. The root module's .bazelrc pins | |
| # --config=remote, which sets --remote_upload_local_results=false | |
| # (internal read-only dev cache). Without overriding it back to true | |
| # on main pushes, root never warms the public cache, so every root | |
| # build is cold forever. Service modules do not set --config=remote, | |
| # so they are unaffected either way. | |
| if [ "$CACHE_UPLOAD" = "true" ]; then | |
| CACHE+=(--remote_upload_local_results=true) | |
| else | |
| CACHE+=(--remote_upload_local_results=false) | |
| fi | |
| fi | |
| # Same cacheless retry as the build step: cache flakiness must never | |
| # fail the matrix (see the build step for the rationale). COMMON keeps | |
| # the non-cache flags (--flaky_test_attempts) on both attempts. | |
| TARGETS=(--target_pattern_file="$ttfile") | |
| if ! bazel test "${COMMON[@]}" "${CACHE[@]}" "${TARGETS[@]}"; then | |
| [ "${CACHE_READY:-0}" = "1" ] || exit 1 | |
| echo "::warning::remote cache test failed; retrying cacheless (local)" | |
| bazel test "${COMMON[@]}" --remote_cache= "${TARGETS[@]}" | |
| fi | |
| - name: Stage Java verification artifacts | |
| if: ${{ always() && startsWith(matrix.subtree.component_kind, 'java-') }} | |
| working-directory: ${{ matrix.subtree.workdir }} | |
| run: | | |
| bash tools/ci/stage-bazel-java-artifacts \ | |
| "${{ matrix.subtree.id }}" \ | |
| "${{ matrix.subtree.path }}" | |
| - name: Upload Java verification artifacts | |
| if: ${{ always() && startsWith(matrix.subtree.component_kind, 'java-') }} | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: bazel-${{ matrix.subtree.id }}-verification-${{ github.run_attempt }} | |
| path: ${{ runner.temp }}/bazel-java-verification/${{ matrix.subtree.id }} | |
| if-no-files-found: error | |
| retention-days: 14 | |
| # Docker-host lane. Each subtree that owns requires-docker | |
| # (Testcontainers) tests runs its FULL suite (unit + Testcontainers) here, in | |
| # its own visibly-named lane, with no tag filter -- so no test a subtree owns | |
| # is filtered out or deferred, and a green lane means that service's tests ran. | |
| # Runs on the bare ubuntu-latest runner (not the bazel-ci container) because | |
| # that runner ships a running Docker daemon, so Testcontainers uses the host | |
| # daemon directly -- no DinD, no host-override networking. These subtrees are | |
| # excluded from the build-container matrix, so they never produce a | |
| # zero-test lane. | |
| # bazelisk reads .bazelversion for the pinned Bazel. | |
| bazel-docker: | |
| name: bazel (${{ matrix.subtree.id }}) | |
| needs: detect | |
| if: needs.detect.outputs.matrix_docker != '[]' | |
| runs-on: ubuntu-latest | |
| # Same public EC2 Buildbarn remote cache the container matrix uses, sourced | |
| # from a repo secret/var. Bare runners reach it over grpcs with a bearer | |
| # token; fork PRs have no secret and fall back to cacheless automatically. | |
| env: | |
| CACHE_TOKEN: ${{ secrets.BAZEL_REMOTE_CACHE_TOKEN }} | |
| CACHE_ENDPOINT: ${{ vars.BAZEL_REMOTE_CACHE_ENDPOINT }} | |
| # Passed as an env var, never interpolated inline into a run: script, so | |
| # its contents can never be parsed as shell (zizmor template-injection). | |
| CACHE_CA: ${{ vars.BAZEL_REMOTE_CACHE_CA }} | |
| strategy: | |
| fail-fast: false | |
| # Cap concurrency here too (see the bazel job) so the docker-host rows | |
| # do not add to the simultaneous actions/checkout burst. | |
| max-parallel: 4 | |
| matrix: | |
| subtree: ${{ fromJson(needs.detect.outputs.matrix_docker) }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| # The Java tree builds with local_jdk (root .bazelrc). Unlike the fast | |
| # matrix, this lane runs on a bare runner rather than the bazel-ci image, | |
| # so provide JDK 25 explicitly for the Java requires-docker tests. | |
| - uses: actions/setup-java@v4 | |
| with: | |
| distribution: temurin | |
| java-version: '25' | |
| - name: Install bazelisk | |
| run: | | |
| sudo curl -fsSLo /usr/local/bin/bazel \ | |
| https://github.com/bazelbuild/bazelisk/releases/download/v1.20.0/bazelisk-linux-amd64 | |
| sudo chmod +x /usr/local/bin/bazel | |
| bazel version | |
| # Cache Bazel's install base + repository cache (external deps: Maven | |
| # artifacts, toolchains) across runs. Compiled action outputs come from | |
| # the remote cache below. | |
| - name: Cache Bazel repository + disk caches | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/.cache/bazel/*/install | |
| ~/.cache/bazel/*/cache | |
| key: bazel-docker-${{ matrix.subtree.workdir == '.' && 'rootmodule' || matrix.subtree.id }}-${{ hashFiles(format('{0}/MODULE.bazel.lock', matrix.subtree.workdir), format('{0}/.bazelversion', matrix.subtree.workdir)) }} | |
| restore-keys: | | |
| bazel-docker-${{ matrix.subtree.workdir == '.' && 'rootmodule' || matrix.subtree.id }}- | |
| # Enable the public EC2 Buildbarn remote cache when the token+endpoint are | |
| # present. Upload only on main pushes so PRs are read-only and cannot | |
| # poison the shared cache. | |
| - name: Prepare remote cache | |
| run: | | |
| if [ -n "$CACHE_TOKEN" ] && [ -n "$CACHE_ENDPOINT" ] && [ -n "$CACHE_CA" ]; then | |
| printf '%s\n' "$CACHE_CA" > "$RUNNER_TEMP/cache-ca.pem" | |
| echo "CACHE_READY=1" >> "$GITHUB_ENV" | |
| upload=false | |
| upload="$(bash "$GITHUB_WORKSPACE/tools/ci/bazel-cache-upload-mode")" | |
| echo "CACHE_UPLOAD=$upload" >> "$GITHUB_ENV" | |
| echo "remote cache ready (upload=$upload)" | |
| else | |
| echo "CACHE_READY=0" >> "$GITHUB_ENV" | |
| echo "remote cache unavailable: cacheless build" | |
| fi | |
| - name: bazel build (${{ matrix.subtree.id }}) | |
| working-directory: ${{ matrix.subtree.workdir }} | |
| run: | | |
| CACHE=(--remote_cache=) | |
| if [ "${CACHE_READY:-0}" = "1" ]; then | |
| CACHE=(--remote_cache="$CACHE_ENDPOINT" | |
| --tls_certificate="$RUNNER_TEMP/cache-ca.pem" | |
| --remote_header="authorization=Bearer $CACHE_TOKEN" | |
| --remote_cache_compression --remote_download_all | |
| --remote_timeout=600 --remote_retries=5) | |
| if [ "${CACHE_UPLOAD:-false}" = "true" ]; then | |
| CACHE+=(--remote_upload_local_results=true) | |
| else | |
| CACHE+=(--remote_upload_local_results=false) | |
| fi | |
| fi | |
| # Capture output so a remote-cache transport error is told apart from a | |
| # genuine build failure: retry cacheless only on the former, otherwise | |
| # every broken PR builds twice (CodeRabbit review on #399). The grep is | |
| # broad on purpose -- a false positive only costs a second attempt (the | |
| # old always-retry behavior), a false negative never hides a real break. | |
| set +e | |
| bazel build "${CACHE[@]}" ${{ matrix.subtree.scope }} 2>&1 | tee "$RUNNER_TEMP/bazel-build.log" | |
| rc=${PIPESTATUS[0]} | |
| set -e | |
| if [ "$rc" -ne 0 ] && [ "${CACHE_READY:-0}" = "1" ] && grep -qiE \ | |
| 'remote (cache|spawn)|StatusRuntimeException|UNAVAILABLE|DEADLINE_EXCEEDED|Bad Gateway|502|lost inputs|BulkTransfer|Failed to (query remote|init TLS)' \ | |
| "$RUNNER_TEMP/bazel-build.log"; then | |
| echo "::warning::remote cache error (exit $rc); retrying cacheless" | |
| bazel build --remote_cache= ${{ matrix.subtree.scope }} | |
| rc=$? | |
| fi | |
| exit "$rc" | |
| - name: bazel test (${{ matrix.subtree.id }}) | |
| working-directory: ${{ matrix.subtree.workdir }} | |
| # Full suite, NO tag filter: unit AND requires-docker (Testcontainers) | |
| # tests against the host Docker daemon. bazel exit 4 (no test targets in | |
| # scope) is treated as success. | |
| run: | | |
| CACHE=(--remote_cache=) | |
| if [ "${CACHE_READY:-0}" = "1" ]; then | |
| CACHE=(--remote_cache="$CACHE_ENDPOINT" | |
| --tls_certificate="$RUNNER_TEMP/cache-ca.pem" | |
| --remote_header="authorization=Bearer $CACHE_TOKEN" | |
| --remote_cache_compression --remote_download_all | |
| --remote_timeout=600 --remote_retries=5) | |
| if [ "${CACHE_UPLOAD:-false}" = "true" ]; then | |
| CACHE+=(--remote_upload_local_results=true) | |
| else | |
| CACHE+=(--remote_upload_local_results=false) | |
| fi | |
| fi | |
| set +e | |
| bazel test "${CACHE[@]}" ${{ matrix.subtree.scope }} \ | |
| --test_output=errors --flaky_test_attempts=2 2>&1 | tee "$RUNNER_TEMP/bazel-test.log" | |
| rc=${PIPESTATUS[0]} | |
| set -e | |
| [ "$rc" -eq 4 ] && { echo "::warning::${{ matrix.subtree.id }}: no test targets in scope"; exit 0; } | |
| # Retry cacheless only on a remote-cache transport error, never on a | |
| # genuine test failure (exit 3) or build failure (1); see the build | |
| # step above for the rationale. | |
| if [ "$rc" -ne 0 ] && [ "${CACHE_READY:-0}" = "1" ] && grep -qiE \ | |
| 'remote (cache|spawn)|StatusRuntimeException|UNAVAILABLE|DEADLINE_EXCEEDED|Bad Gateway|502|lost inputs|BulkTransfer|Failed to (query remote|init TLS)' \ | |
| "$RUNNER_TEMP/bazel-test.log"; then | |
| echo "::warning::remote cache error (exit $rc); retrying cacheless" | |
| set +e; bazel test --remote_cache= ${{ matrix.subtree.scope }} --test_output=errors --flaky_test_attempts=2; rc=$?; set -e | |
| [ "$rc" -eq 4 ] && exit 0 | |
| fi | |
| exit "$rc" | |
| - name: Stage Java verification artifacts | |
| if: ${{ always() && startsWith(matrix.subtree.component_kind, 'java-') }} | |
| working-directory: ${{ matrix.subtree.workdir }} | |
| run: | | |
| bash tools/ci/stage-bazel-java-artifacts \ | |
| "${{ matrix.subtree.id }}" \ | |
| "${{ matrix.subtree.path }}" | |
| - name: Upload Java verification artifacts | |
| if: ${{ always() && startsWith(matrix.subtree.component_kind, 'java-') }} | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: bazel-${{ matrix.subtree.id }}-verification-${{ github.run_attempt }} | |
| path: ${{ runner.temp }}/bazel-java-verification/${{ matrix.subtree.id }} | |
| if-no-files-found: error | |
| retention-days: 14 | |
| bazel-verification: | |
| name: bazel required checks | |
| needs: [detect, bazel, bazel-docker] | |
| if: ${{ always() }} | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Check Bazel workflow result | |
| env: | |
| DETECT_RESULT: ${{ needs.detect.result }} | |
| BAZEL_RESULT: ${{ needs.bazel.result }} | |
| DOCKER_RESULT: ${{ needs.bazel-docker.result }} | |
| BAZEL_ANY: ${{ needs.detect.outputs.any }} | |
| run: | | |
| set -euo pipefail | |
| echo "detect result: ${DETECT_RESULT}" | |
| echo "bazel result: ${BAZEL_RESULT}" | |
| echo "bazel selected subtrees: ${BAZEL_ANY}" | |
| if [ "${DETECT_RESULT}" != "success" ]; then | |
| echo "detect changed subtrees did not complete successfully" | |
| exit 1 | |
| fi | |
| if [ "${BAZEL_ANY}" != "true" ]; then | |
| echo "no Bazel-relevant subtrees selected" | |
| exit 0 | |
| fi | |
| # `skipped` is legitimate here and means the build-container matrix was | |
| # empty, i.e. every selected subtree runs in the docker-host lane. The | |
| # job's `if` is tied to that matrix being non-empty, so skipped can | |
| # only mean "nothing for this lane to do" -- it cannot hide a row that | |
| # should have run. The BAZEL_ANY check above already catches the case | |
| # where no subtree was selected at all. | |
| if [ "${BAZEL_RESULT}" != "success" ] && [ "${BAZEL_RESULT}" != "skipped" ]; then | |
| echo "one or more Bazel matrix rows failed or were cancelled" | |
| exit 1 | |
| fi | |
| echo "bazel-docker result: ${DOCKER_RESULT}" | |
| if [ "${DOCKER_RESULT}" != "success" ] && [ "${DOCKER_RESULT}" != "skipped" ]; then | |
| echo "one or more bazel docker (per-service Testcontainers) lanes failed or were cancelled" | |
| exit 1 | |
| fi |