fix(charts): bump chart versions for released services #882
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) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | |
| # SPDX-License-Identifier: Apache-2.0 | |
| # Manually build a service's multi-arch image (linux/amd64 + linux/arm64) | |
| # from the selected ref and push it to the internal NGC dev registry so it is | |
| # pullable by NVCF clusters for pre-merge testing -- the GitHub-side | |
| # counterpart to the GitLab "<svc>-image-push-mr-manual" jobs. | |
| # | |
| # Manual (workflow_dispatch) only; no image is pushed automatically. | |
| # Requires repo configuration (set in GitHub repo settings, not in source): | |
| # - secret NGC_NCP_DEV_KEY : NGC key with push to the ncp-dev registry | |
| # - secret NCP_DEV_REGISTRY : the ncp-dev registry base (host/tenant/repo-prefix) | |
| # workflow_dispatch is restricted to users with write access; fork PRs cannot | |
| # read these, so the credential is not exposed to untrusted contributors. | |
| name: image-push (manual) | |
| on: | |
| # Label a PR with deploy-to-stg and every subsequent push builds and pushes a | |
| # dev image automatically. Removing the label stops it. `labeled` covers the | |
| # first build, `synchronize` each push after that, `reopened` a revived PR. | |
| # | |
| # Fork PRs are filtered out in the resolve job below; pull_request (not | |
| # pull_request_target) is used, so untrusted code never runs with secrets. | |
| pull_request: | |
| types: [labeled, synchronize, reopened] | |
| workflow_dispatch: | |
| inputs: | |
| service_path: | |
| description: >- | |
| Service subtree to build and push, e.g. | |
| src/invocation-plane-services/grpc-proxy. Any subtree with an | |
| oci_image_index target works; the list is not maintained here. | |
| required: true | |
| type: string | |
| permissions: | |
| contents: read | |
| # Required to pull the private ghcr.io/nvidia/nvcf/bazel-ci job container. | |
| packages: read | |
| concurrency: | |
| group: image-push-${{ github.event.inputs.service_path || github.ref }} | |
| # A superseded PR build is worthless: the tag it would produce is for a commit | |
| # nobody will deploy. Dispatch runs are deliberate, so they still queue. | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| jobs: | |
| resolve: | |
| name: resolve subtree | |
| runs-on: ubuntu-latest | |
| # Skip anything not asking for a build, without failing the PR. | |
| # | |
| # Fork PRs are excluded here rather than left to fail at the push step: | |
| # they receive no secrets, so they would burn a runner and end in a | |
| # confusing auth error. | |
| # | |
| # The last clause matters because `labeled` fires for every label. Without | |
| # it, adding an unrelated label to a PR that already carries deploy-to-stg | |
| # would trigger a full rebuild. On `synchronize` and `reopened` there is no | |
| # github.event.label, so the clause is true and the contains() check governs. | |
| if: >- | |
| github.event_name == 'workflow_dispatch' || | |
| ( | |
| github.event.pull_request.head.repo.full_name == github.repository && | |
| contains(github.event.pull_request.labels.*.name, 'deploy-to-stg') && | |
| (github.event.action != 'labeled' || github.event.label.name == 'deploy-to-stg') | |
| ) | |
| outputs: | |
| service_path: ${{ steps.pick.outputs.service_path }} | |
| found: ${{ steps.pick.outputs.found }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Pick the subtree to build | |
| id: pick | |
| env: | |
| INPUT_PATH: ${{ github.event.inputs.service_path }} | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: | | |
| set -euo pipefail | |
| if [ -n "${INPUT_PATH}" ]; then | |
| echo "service_path=${INPUT_PATH}" >> "$GITHUB_OUTPUT" | |
| echo "found=true" >> "$GITHUB_OUTPUT" | |
| echo "dispatch: ${INPUT_PATH}" | |
| exit 0 | |
| fi | |
| # A PR event carries no path input, so derive it from what changed. | |
| # Service subtrees are src/<plane>/<service>; take that prefix from | |
| # every changed file and require exactly one, because a dev image is | |
| # for one service and guessing between several would be wrong. | |
| # Three dots, not two: compare the merge base of the two commits with | |
| # the head, so only this PR's own changes are considered. A two-dot | |
| # diff also reports whatever landed on the base branch after this PR | |
| # forked, so an unrelated service merging to main would make a | |
| # single-service PR look like a multi-service one and skip its image. | |
| mapfile -t subtrees < <( | |
| git diff --name-only "${BASE_SHA}...${HEAD_SHA}" \ | |
| | grep -E '^src/[^/]+/[^/]+/' \ | |
| | cut -d/ -f1-3 | sort -u | |
| ) | |
| if [ "${#subtrees[@]}" -eq 1 ] && [ -f "${subtrees[0]}/BUILD.bazel" ]; then | |
| echo "service_path=${subtrees[0]}" >> "$GITHUB_OUTPUT" | |
| echo "found=true" >> "$GITHUB_OUTPUT" | |
| echo "resolved from changed files: ${subtrees[0]}" | |
| exit 0 | |
| fi | |
| # Three distinct reasons to skip. They need distinct messages: the | |
| # right next step differs, and a wrong explanation sends people to | |
| # workflow_dispatch when dispatch would not help either. | |
| echo "found=false" >> "$GITHUB_OUTPUT" | |
| if [ "${#subtrees[@]}" -eq 0 ]; then | |
| echo "::notice::No service subtree changed; nothing to push." | |
| elif [ "${#subtrees[@]}" -eq 1 ]; then | |
| echo "::notice::${subtrees[0]} has no BUILD.bazel, so it is not a buildable service subtree; nothing to push." | |
| else | |
| echo "::notice::Changed ${#subtrees[@]} subtrees (${subtrees[*]}); a dev image targets one. Use workflow_dispatch to pick one." | |
| fi | |
| push: | |
| name: push to ncp-dev | |
| needs: resolve | |
| if: needs.resolve.outputs.found == 'true' | |
| runs-on: ubuntu-latest | |
| # Same public EC2 Buildbarn cache the matrix build uses. workflow_dispatch | |
| # is restricted to users with write access, so the secret is available; | |
| # fork PRs cannot reach this workflow at all. | |
| env: | |
| CACHE_TOKEN: ${{ secrets.BAZEL_REMOTE_CACHE_TOKEN }} | |
| CACHE_ENDPOINT: ${{ vars.BAZEL_REMOTE_CACHE_ENDPOINT }} | |
| container: | |
| image: ${{ vars.BAZEL_CI_IMAGE || 'ghcr.io/nvidia/nvcf/bazel-ci:0.14.0' }} | |
| defaults: | |
| run: | |
| # In container jobs Actions falls back to plain `sh` (dash), which | |
| # rejects `set -o pipefail` and kills every script step at line 1. | |
| shell: bash | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| # actions/checkout runs on the runner host; this job's steps run inside | |
| # the bazel-ci container, which mounts that checkout under a different | |
| # UID. Since git 2.35.2 (CVE-2022-24765) git refuses to operate on a | |
| # repo it does not own ("detected dubious ownership"), which makes | |
| # workspace_status.sh's `git rev-parse --short HEAD` fail silently | |
| # (stderr redirected) and fall back to the literal "unknown", landing | |
| # in the built binary as `mr-unknown` instead of a real short SHA. | |
| - name: Trust the checkout inside the container | |
| run: git config --global --add safe.directory "$GITHUB_WORKSPACE" | |
| - name: Resolve service name for cache keys | |
| id: svc | |
| env: | |
| SVC_PATH: ${{ needs.resolve.outputs.service_path }} | |
| run: | | |
| set -euo pipefail | |
| echo "name=$(basename "$SVC_PATH")" >> "$GITHUB_OUTPUT" | |
| # Two caches, because the content has two different identities. | |
| # | |
| # The install base and repository cache are a pure function of the root | |
| # MODULE.bazel.lock and .bazelversion: every service dispatched from this | |
| # workflow produces byte-identical content. Storing that per service would | |
| # keep N copies of the same multi-gigabyte entry inside GitHub's 10 GB | |
| # per-repository budget, so they would evict one another and nothing would | |
| # ever hit. It is shared, and shares the key the matrix build uses so a | |
| # dispatch can restore what CI already populated. | |
| # | |
| # Compiled action outputs in the disk cache genuinely are service-specific | |
| # and stay keyed per service. | |
| # | |
| # Both previously hashed a MODULE.bazel.lock inside the service subtree. | |
| # Those files are gone now that every service builds from the root module, | |
| # so hashFiles returned empty and the key degenerated to a constant: the | |
| # entry never matched the dependency state it claimed to describe. | |
| - name: Cache Bazel repository + install | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/.cache/bazel/_bazel_${{ env.USER || 'root' }}/install | |
| ~/.cache/bazel/_bazel_${{ env.USER || 'root' }}/cache | |
| key: bazel-rootmodule-${{ hashFiles('MODULE.bazel.lock', '.bazelversion') }} | |
| restore-keys: | | |
| bazel-rootmodule- | |
| - name: Cache Bazel disk cache | |
| uses: actions/cache@v4 | |
| with: | |
| path: ~/.bazel-disk-cache | |
| key: bazel-dispatch-${{ steps.svc.outputs.name }}-${{ hashFiles('MODULE.bazel.lock', '.bazelversion') }} | |
| restore-keys: | | |
| bazel-dispatch-${{ steps.svc.outputs.name }}- | |
| # Read-only remote cache, same public Buildbarn the matrix build uses. | |
| # This workflow previously passed --remote_cache= (empty, i.e. disabled) | |
| # and relied only on a local disk cache, so every dispatch recompiled the | |
| # service's entire dependency closure even though CI had already built | |
| # those exact actions on main. Reusing tools/ci/bazel-cache-upload-mode | |
| # keeps the upload policy in one place; a workflow_dispatch is neither a | |
| # main push nor a merge_group, so it resolves to read-only and an ad-hoc | |
| # push can never write to the shared cache. | |
| - name: Prepare remote cache | |
| id: rc | |
| run: | | |
| set -euo pipefail | |
| if [ -n "${CACHE_TOKEN:-}" ] && [ -n "${CACHE_ENDPOINT:-}" ]; then | |
| printf '%s\n' "${{ vars.BAZEL_REMOTE_CACHE_CA }}" > "$RUNNER_TEMP/cache-ca.pem" | |
| upload="$(bash "$GITHUB_WORKSPACE/tools/ci/bazel-cache-upload-mode")" | |
| echo "ready=1" >> "$GITHUB_OUTPUT" | |
| echo "upload=$upload" >> "$GITHUB_OUTPUT" | |
| echo "remote cache ready (upload=$upload)" | |
| else | |
| echo "ready=0" >> "$GITHUB_OUTPUT" | |
| echo "remote cache unavailable (no token/endpoint): local disk cache only" | |
| fi | |
| - name: Compute snapshot tag | |
| id: meta | |
| env: | |
| RUN_NUMBER: ${{ github.run_number }} | |
| SHA: ${{ github.sha }} | |
| run: | | |
| set -euo pipefail | |
| # Tags are always machine-derived (never user input) so every | |
| # published snapshot is deterministic and traceable to its run+commit. | |
| tag="gh.${RUN_NUMBER}-$(printf '%s' "${SHA}" | cut -c1-8)" | |
| echo "tag=$tag" >> "$GITHUB_OUTPUT" | |
| - name: Authenticate to the NGC dev registry | |
| env: | |
| NGC_KEY: ${{ secrets.NGC_NCP_DEV_KEY }} | |
| REGISTRY: ${{ secrets.NCP_DEV_REGISTRY }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "${NGC_KEY}" ] || [ -z "${REGISTRY}" ]; then | |
| echo "ERROR: set secrets NGC_NCP_DEV_KEY and NCP_DEV_REGISTRY in repo settings" >&2 | |
| exit 1 | |
| fi | |
| # Tolerate a trailing slash in the secret value: appending the | |
| # image name to "host/tenant/prefix/" yields "prefix//name", | |
| # which nvcr.io rejects with NAME_INVALID. | |
| REGISTRY="${REGISTRY%/}" | |
| mkdir -p "$HOME/.docker" | |
| auth="$(printf '$oauthtoken:%s' "${NGC_KEY}" | base64 -w0)" | |
| # Scope the auth key to the push repo prefix (NOT the bare host) so | |
| # rules_oci does not apply this token to the public base-image pull | |
| # (e.g. distroless), which would 403. Mirrors the NVCF-10337 fix. | |
| printf '{"auths":{"%s":{"auth":"%s"}}}\n' "${REGISTRY}" "${auth}" > "$HOME/.docker/config.json" | |
| chmod 600 "$HOME/.docker/config.json" | |
| # Resolve where Bazel must run and how to scope the query. A subtree with | |
| # its own MODULE.bazel is queried from inside itself; a service that | |
| # lives in the repo-root module (the Java services) is queried from the | |
| # root with a path-scoped pattern. Deriving this rather than hardcoding | |
| # it means a new service needs no change to this workflow. | |
| - name: Resolve Bazel module layout | |
| id: layout | |
| env: | |
| SVC_PATH: ${{ needs.resolve.outputs.service_path }} | |
| run: | | |
| set -euo pipefail | |
| if [ ! -d "$SVC_PATH" ]; then | |
| echo "ERROR: no such subtree: $SVC_PATH" >&2 | |
| echo "Subtrees owning a Bazel module:" >&2 | |
| find src -maxdepth 4 -name MODULE.bazel -printf ' %h\n' 2>/dev/null | sort >&2 | |
| exit 1 | |
| fi | |
| if [ -f "$SVC_PATH/MODULE.bazel" ]; then | |
| echo "workdir=$SVC_PATH" >> "$GITHUB_OUTPUT" | |
| echo "scope=//..." >> "$GITHUB_OUTPUT" | |
| echo "layout: standalone module rooted at $SVC_PATH" | |
| else | |
| echo "workdir=." >> "$GITHUB_OUTPUT" | |
| echo "scope=//$SVC_PATH/..." >> "$GITHUB_OUTPUT" | |
| echo "layout: root module, scoped to //$SVC_PATH/..." | |
| fi | |
| - name: Build and push multi-arch image(s) | |
| working-directory: ${{ steps.layout.outputs.workdir }} | |
| env: | |
| SVC_PATH: ${{ needs.resolve.outputs.service_path }} | |
| SCOPE: ${{ steps.layout.outputs.scope }} | |
| TAG: ${{ steps.meta.outputs.tag }} | |
| REGISTRY: ${{ secrets.NCP_DEV_REGISTRY }} | |
| RC_READY: ${{ steps.rc.outputs.ready }} | |
| RC_UPLOAD: ${{ steps.rc.outputs.upload }} | |
| run: | | |
| set -euo pipefail | |
| REGISTRY="${REGISTRY%/}" | |
| svc="$(basename "$SVC_PATH")" | |
| export BAZEL_DISK_CACHE="${HOME}/.bazel-disk-cache" | |
| # Run the query directly rather than inside process substitution. | |
| # With `mapfile < <(bazel query ...)` only stdout reaches mapfile, so a | |
| # failing query (BUILD error, unloadable package) yields an empty array | |
| # and would be misreported below as "no image targets" instead of | |
| # surfacing the real error. | |
| if ! query_out="$(bazel query --remote_cache= "kind(\"oci_image_index\", ${SCOPE})")"; then | |
| echo "ERROR: bazel query failed for scope ${SCOPE}" >&2 | |
| exit 1 | |
| fi | |
| # Build the array by hand: a here-string of empty output would produce | |
| # a single empty element rather than an empty array. | |
| indexes=() | |
| while IFS= read -r line; do | |
| [ -n "$line" ] && indexes+=("$line") | |
| done <<< "$query_out" | |
| if [ "${#indexes[@]}" -eq 0 ]; then | |
| echo "ERROR: no oci_image_index targets under ${SVC_PATH}" >&2 | |
| echo "The subtree must declare an image target (go_oci_image, java_oci_image, ...)." >&2 | |
| exit 1 | |
| fi | |
| echo "discovered: ${indexes[*]}" | |
| for tgt in "${indexes[@]}"; do | |
| name="${tgt##*:}"; name="${name%_index}" | |
| # Two naming conventions exist in the tree and they mean different | |
| # things, distinguished by the separator: | |
| # image -> the service's sole image; repo is the service | |
| # <component>_image -> a sub-component; repo is <service>-<component> | |
| # (nvcf-unbound webhook, llm-api-gateway | |
| # rate_limit_sync_worker, nvsnap agent/server) | |
| # <image-name>-image -> the target already carries the full image | |
| # name; use it as-is, do NOT prefix the | |
| # service (byoo-otel-collector, cloud-tasks) | |
| # Previously the hyphenated form fell through to the default and | |
| # produced names like byoo-otel-collector-byoo-otel-collector-image. | |
| case "$name" in | |
| image) repo="${svc}" ;; | |
| *_image) sub="$(printf '%s' "${name%_image}" | tr '_' '-')"; repo="${svc}-${sub}" ;; | |
| *-image) repo="${name%-image}" ;; | |
| *) sub="$(printf '%s' "$name" | tr '_' '-')"; repo="${svc}-${sub}" ;; | |
| esac | |
| dest="${REGISTRY}/${repo}" | |
| echo "[push] ${tgt} -> ${dest}:${TAG} (+ latest-dispatch)" | |
| mkdir -p ci-ghcr | |
| printf 'load("@rules_oci//oci:defs.bzl", "oci_push")\n\noci_push(\n name = "push",\n image = "%s",\n repository = "%s",\n remote_tags = ["%s", "latest-dispatch"],\n)\n' \ | |
| "$tgt" "$dest" "$TAG" > ci-ghcr/BUILD.bazel | |
| CACHE=(--remote_cache=) | |
| if [ "${RC_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 [ "$RC_UPLOAD" = "true" ]; then | |
| CACHE+=(--remote_upload_local_results=true) | |
| else | |
| CACHE+=(--remote_upload_local_results=false) | |
| fi | |
| fi | |
| bazel run "${CACHE[@]}" --disk_cache="${BAZEL_DISK_CACHE}" //ci-ghcr:push | |
| rm -rf ci-ghcr | |
| done | |
| echo "Done: pushed ${#indexes[@]} image(s) under ${REGISTRY}/ at tag ${TAG}" |