image-push (manual) #14
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: | |
| 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 }} | |
| cancel-in-progress: false | |
| jobs: | |
| push: | |
| name: push to ncp-dev | |
| runs-on: ubuntu-latest | |
| 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 | |
| - name: Resolve service name for cache keys | |
| id: svc | |
| env: | |
| SVC_PATH: ${{ github.event.inputs.service_path }} | |
| run: | | |
| set -euo pipefail | |
| echo "name=$(basename "$SVC_PATH")" >> "$GITHUB_OUTPUT" | |
| # Same repository/install caching as bazel.yml, plus a Bazel disk | |
| # cache so compiled actions survive between dispatches. Without | |
| # this every dispatch recompiled the full dependency tree from | |
| # scratch. Keyed per service so caches do not evict each other; | |
| # the restore-keys prefix tolerates lockfile drift. | |
| - name: Cache Bazel repository + disk caches | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/.cache/bazel/_bazel_${{ env.USER || 'root' }}/install | |
| ~/.cache/bazel/_bazel_${{ env.USER || 'root' }}/cache | |
| ~/.bazel-disk-cache | |
| key: bazel-dispatch-${{ steps.svc.outputs.name }}-${{ hashFiles(format('{0}/MODULE.bazel.lock', github.event.inputs.service_path), format('{0}/.bazelversion', github.event.inputs.service_path)) }} | |
| restore-keys: | | |
| bazel-dispatch-${{ steps.svc.outputs.name }}- | |
| - 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: ${{ github.event.inputs.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: ${{ github.event.inputs.service_path }} | |
| SCOPE: ${{ steps.layout.outputs.scope }} | |
| TAG: ${{ steps.meta.outputs.tag }} | |
| REGISTRY: ${{ secrets.NCP_DEV_REGISTRY }} | |
| 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 | |
| bazel run --remote_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}" |