Skip to content

image-push (manual) #14

image-push (manual)

image-push (manual) #14

# 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}"