Skip to content

fix(version): prevent panic on empty or invalid version string in Compare #1401

fix(version): prevent panic on empty or invalid version string in Compare

fix(version): prevent panic on empty or invalid version string in Compare #1401

Workflow file for this run

# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Build when operator code changes
name: Operator CI
on:
workflow_dispatch: {}
pull_request:
paths:
- operator/**/*.go
- operator/go.mod
- operator/go.sum
- operator/deps.mk
- operator/versions.yaml
- operator/versions.sh
- scripts/latest-distroless.sh
- operator/config/**
- containers/operator.Dockerfile
- .github/actions/**
- .github/workflows/operator-ci.yaml
- k8s-tests/**
- chart/**
push:
branches:
- main
tags:
- operator/*
paths:
- operator/**/*.go
- operator/go.mod
- operator/go.sum
- operator/deps.mk
- operator/versions.yaml
- operator/versions.sh
- scripts/latest-distroless.sh
- operator/config/**
- containers/operator.Dockerfile
- .github/actions/**
- .github/workflows/operator-ci.yaml
- k8s-tests/**
- chart/**
## these envs control the build and test process below
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
DEBIAN_VERSION: trixie
# Opt all JS actions into Node 24. Node 20 is being phased out by GitHub
# Actions starting June 2026; this avoids the deprecation warnings without
# waiting for each action (checkout/setup-go/cache/upload-artifact) to
# publish a Node 24 release.
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
PLATFORMS: linux/amd64,linux/arm64
PUSH_TO_REGISTRY: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
jobs:
k8s-test-versions:
name: Load Kubernetes Test Versions
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
envtest-k8s-version: ${{ steps.versions.outputs.envtest-k8s-version }}
kind-node-image-version: ${{ steps.versions.outputs.kind-nodeimage }}
kind-binary-version: ${{ steps.versions.outputs.kind-binary }}
ci-kind-node-image-versions-json: ${{ steps.versions.outputs.ci-kindnodeimages }}
ci-primary-kind-node-image-version: ${{ steps.versions.outputs.ci-primarykindnodeimage }}
steps:
- uses: actions/checkout@v7
- name: Load versions
id: versions
run: |
cd operator
make yq
YQ="$PWD/bin/yq" ./versions.sh --print >> "$GITHUB_OUTPUT"
fetch-distroless-versions:
name: Resolve Distroless Base Image
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
distroless-version: ${{ steps.fetch.outputs.distroless-version }}
distroless-digest: ${{ steps.fetch.outputs.distroless-digest }}
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/setup-oras
- name: Resolve newest published distroless/static
id: fetch
run: |
# The operator Dockerfile bases on distroless/static, so resolve the
# static repository, not go.
./scripts/latest-distroless.sh \
--repo nvcr.io/nvidia/distroless/static \
--major 4 \
--print | tee -a "$GITHUB_OUTPUT"
# Test operator across supported Kubernetes versions and test suites
tests:
runs-on: ubuntu-latest
needs: [k8s-test-versions]
strategy:
matrix:
# Standard E2E tests on all supported K8s versions
k8s-version: ${{ fromJson(needs.k8s-test-versions.outputs.ci-kind-node-image-versions-json) }}
test-suite: ["e2e"]
pool: ["core", "interrupt", "uninstall", "lifecycle"]
make-targets: ["setup-kind-cluster e2e-tests"]
include:
# Deployment policy tests on 15-node cluster use the primary Kind node image.
- k8s-version: ${{ needs.k8s-test-versions.outputs.ci-primary-kind-node-image-version }}
test-suite: deployment-policy
kind-config: k8s-tests/chainsaw/deployment-policy/kind-config.yaml
make-targets: "setup-kind-cluster deployment-policy-tests"
# Focus the heavier suites on the primary Kind node image.
- k8s-version: ${{ needs.k8s-test-versions.outputs.ci-primary-kind-node-image-version }}
test-suite: cli-e2e
make-targets: "setup-kind-cluster cli-e2e-tests"
- k8s-version: ${{ needs.k8s-test-versions.outputs.ci-primary-kind-node-image-version }}
test-suite: unit-tests
make-targets: "vet lint unit-tests"
- k8s-version: ${{ needs.k8s-test-versions.outputs.ci-primary-kind-node-image-version }}
test-suite: helm-tests
make-targets: "helm-tests"
# Skyhook -> NodeWright upgrade migration. Installs the last pre-rename
# release (chart/v0.17.1 @ operator v0.17.0) and upgrades to this commit.
# Needs the ctlptl cluster for its local registry, like helm-tests, and a
# full-depth checkout with tags so `git archive chart/v0.17.1` resolves.
# Primary node image only: this exercises the operator's own upgrade path,
# which is not K8s-version sensitive, and the run is long enough that a
# matrix over every version would not pay for itself.
- k8s-version: ${{ needs.k8s-test-versions.outputs.ci-primary-kind-node-image-version }}
test-suite: migration
make-targets: "migration-test"
# Operator <-> agent contract. agent-ci runs this same suite when the AGENT
# changes, against a freshly built agent; nothing ran it when the OPERATOR
# changed, even though the operator is what builds the pod the agent runs in
# -- its args, mounts, copy dir and config.json. Every other suite here uses
# the agentless package image, which never reads that config, so a break in
# the contract could reach a release unnoticed. This row runs against the
# agent version the chart ships, which is what users pair the operator with.
- k8s-version: ${{ needs.k8s-test-versions.outputs.ci-primary-kind-node-image-version }}
test-suite: operator-agent
make-targets: "setup-kind-cluster operator-agent-tests"
fail-fast: false # Continue testing other versions if one fails
name: ${{ matrix.test-suite }}${{ matrix.pool && format('/{0}', matrix.pool) || '' }} (k8s-${{ matrix.k8s-version }})
steps:
- uses: actions/checkout@v7
with:
fetch-tags: true
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v7
with:
go-version-file: operator/go.mod
cache-dependency-path: operator/go.sum
- name: Log in to the Container registry
if: matrix.test-suite != 'unit-tests' # unit tests don't need a container registry login
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Cache build tools and dependencies for faster builds
- name: Restore cached Binaries
id: cached-binaries
uses: actions/cache/restore@v6
with:
key: ${{ runner.os }}-${{ runner.arch }}-bin-${{ hashFiles('operator/go.mod', 'operator/deps.mk', 'operator/versions.yaml', 'operator/versions.sh') }}
# Intentionally identical to key (NO prefix fallback): a looser prefix
# restore-keys hit stale tool caches that were not rebuilt when deps
# changed. Keep exact-match so a deps change forces a cold rebuild.
restore-keys: ${{ runner.os }}-${{ runner.arch }}-bin-${{ hashFiles('operator/go.mod', 'operator/deps.mk', 'operator/versions.yaml', 'operator/versions.sh') }}
path: |
${{ github.workspace }}/operator/bin
~/.cache/go-build
- name: Install dependencies
if: steps.cached-binaries.outputs.cache-hit != 'true'
run: |
cd operator
make install-deps
- name: Save cached Binaries
id: save-cached-binaries
if: steps.cached-binaries.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
key: ${{ runner.os }}-${{ runner.arch }}-bin-${{ hashFiles('operator/go.mod', 'operator/deps.mk', 'operator/versions.yaml', 'operator/versions.sh') }}
path: |
${{ github.workspace }}/operator/bin
~/.cache/go-build
- name: Validate KinD node image v${{ matrix.k8s-version }}
if: matrix.test-suite != 'unit-tests' # unit tests don't need a cluster
env:
K8S_VERSION: ${{ matrix.k8s-version }}
run: |
cd operator
make validate-kind-node-image KIND_NODE_IMAGE_VERSION="$K8S_VERSION"
# unit-tests need no cluster; helm-tests and migration build their own via
# ctlptl because they need its local image registry to serve the operator
# image under test.
- name: Create Kubernetes KinD Cluster v${{ matrix.k8s-version }}
if: matrix.test-suite != 'unit-tests' && !contains(fromJSON('["helm-tests","migration"]'), matrix.test-suite)
id: kind
uses: helm/kind-action@v1.15.0
with:
version: ${{ needs.k8s-test-versions.outputs.kind-binary-version }}
node_image: kindest/node:v${{ matrix.k8s-version }}
config: ${{ matrix.kind-config || 'operator/config/local-dev/kind-config.yaml' }}
cluster_name: kind
- name: Install kind
if: contains(fromJSON('["helm-tests","migration"]'), matrix.test-suite)
env:
KIND_BINARY_VERSION: ${{ needs.k8s-test-versions.outputs.kind-binary-version }}
run: |
curl -fsSL --retry 3 --retry-delay 5 --retry-all-errors -o /tmp/kind-linux-amd64 "https://kind.sigs.k8s.io/dl/${KIND_BINARY_VERSION}/kind-linux-amd64"
curl -fsSL --retry 3 --retry-delay 5 --retry-all-errors -o /tmp/kind-linux-amd64.sha256sum "https://kind.sigs.k8s.io/dl/${KIND_BINARY_VERSION}/kind-linux-amd64.sha256sum"
cd /tmp && sha256sum -c kind-linux-amd64.sha256sum
sudo install /tmp/kind-linux-amd64 /usr/local/bin/kind
- name: Create ctlptl KinD Cluster
if: contains(fromJSON('["helm-tests","migration"]'), matrix.test-suite)
env:
K8S_VERSION: ${{ matrix.k8s-version }}
run: |
cd operator
make create-kind-cluster KIND_NODE_IMAGE_VERSION="$K8S_VERSION"
# operator-agent-tests refuses to run without an explicit AGENT_IMAGE, because the
# Makefile's global default is the agentless image -- which would pass every case
# while executing nothing. Read the pin out of the chart rather than hardcoding a
# version here, so bumping the agent in one place cannot leave this row testing an
# older one.
- name: Resolve the chart's pinned agent image
if: matrix.test-suite == 'operator-agent'
run: |
## operator/bin/yq, not the runner's: install-deps pins the version and the
## binaries are already restored from cache by this point in the job.
YQ=operator/bin/yq
repo=$("$YQ" -r '.controllerManager.manager.agent.repository' chart/values.yaml)
tag=$("$YQ" -r '.controllerManager.manager.agent.tag' chart/values.yaml)
if [ -z "$repo" ] || [ -z "$tag" ] || [ "$repo" = "null" ] || [ "$tag" = "null" ]; then
echo "could not read the agent image from chart/values.yaml"
exit 1
fi
echo "AGENT_IMAGE=${repo}:${tag}" >> "$GITHUB_ENV"
echo "operator-agent tests will use ${repo}:${tag}"
# Run test suite
- name: Run ${{ matrix.test-suite }} tests
env:
MAKE_TARGETS: ${{ matrix.make-targets }}
POOL: ${{ matrix.pool }}
run: |
cd operator
read -r -a MAKE_TARGETS_ARRAY <<< "$MAKE_TARGETS"
make "${MAKE_TARGETS_ARRAY[@]}" merge-coverage
# Save coverage artifacts from any test suite that generates them
- name: Upload coverage artifact
if: hashFiles('operator/reporting/cover.out') != ''
uses: actions/upload-artifact@v7
with:
name: coverage-${{ matrix.test-suite }}${{ matrix.pool && format('-{0}', matrix.pool) || '' }}-k8s-${{ matrix.k8s-version }}
path: operator/reporting/cover.out
retention-days: 1
if-no-files-found: ignore
# Merge coverage from all test suites and upload to Coveralls
upload-coverage:
runs-on: ubuntu-latest
needs: [tests]
# Only upload coverage for PRs and main branch pushes, not for tags
if: success() && !startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v7
with:
go-version-file: operator/go.mod
- name: Download all coverage artifacts
uses: actions/download-artifact@v8
with:
pattern: coverage-*
path: coverage-artifacts
merge-multiple: false
- name: Merge coverage files
run: |
cd operator
mkdir -p reporting
# Combine all coverage files
for file in ../coverage-artifacts/*/cover.out; do
if [ -f "$file" ]; then
echo "Merging coverage from $file"
cat "$file" >> reporting/all-cover.out
fi
done
# Create final merged coverage file
echo "mode: set" > reporting/cover.out
tail -n +2 reporting/all-cover.out | sed '/mode: set/d' >> reporting/cover.out
# Re-apply the exclusions. The per-suite artifacts are already filtered
# by merge-coverage, so this is belt-and-braces for a suite that starts
# uploading a raw profile — Coveralls counts whatever it is handed and
# has no exclusion mechanism of its own. COVERAGE_EXCLUDE is defined
# once, in operator/Makefile.
make filter-coverage
# Show total coverage
echo "📊 Total Combined Coverage:"
go tool cover -func reporting/cover.out | grep total
- name: Upload to Coveralls
uses: coverallsapp/github-action@v2.3.8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
file: operator/reporting/cover.out
format: golang
# Compute image tags and version metadata once for reuse
compute-metadata:
runs-on: ubuntu-latest
needs: [tests, fetch-distroless-versions]
outputs:
git-sha: ${{ steps.meta.outputs.git-sha }}
version: ${{ steps.meta.outputs.version }}
tags: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v7
- name: Fetch all tags
run: git fetch --tags --force
- name: Compute metadata
id: meta
run: |
export GIT_SHA=$(git rev-parse --short ${{ github.sha }})
echo "git-sha=${GIT_SHA}" >> $GITHUB_OUTPUT
case ${{ github.ref_type }} in
branch)
export VERSION=$(git tag --list 'operator*' --sort=-v:refname | head -n 1 | cut -d/ -f2)+${GIT_SHA}
TAGS="${GIT_SHA} $(echo "${VERSION}" | tr + -)"
;;
tag)
export VERSION=$(echo "${{ github.ref_name }}" | cut -f 2 -d /)
TAGS="${GIT_SHA} ${VERSION} latest"
;;
*)
echo "Unknown ref type: ${{ github.ref_type }}"
exit 1
;;
esac
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
echo "📦 Version: ${VERSION}"
echo "🏷️ Tags: ${TAGS}"
# Build container images on native architecture runners (much faster than QEMU)
build-operator:
runs-on: ${{ matrix.runner }}
needs: [compute-metadata, fetch-distroless-versions]
strategy:
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Fetch all tags
run: git fetch --tags --force
- name: Log in to the Container registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
# Build and tag container image for single platform on native hardware
- name: Build the operator container image (${{ matrix.platform }})
id: build
env:
GIT_SHA: ${{ needs.compute-metadata.outputs.git-sha }}
VERSION: ${{ needs.compute-metadata.outputs.version }}
PLATFORM: ${{ matrix.platform }}
run: |
cd operator
GO_VERSION=$(awk '/^go /{go=$2} /^toolchain /{tc=$2; sub(/^go/,"",tc)} END{print (tc!=""?tc:go)}' go.mod)
PLATFORM_TAG=$(echo "$PLATFORM" | tr '/' '-')
# Lowercase for Docker compliance
IMAGE_NAME=$(echo "${{env.IMAGE_NAME}}" | tr '[:upper:]' '[:lower:]')
REGISTRY=$(echo "${{env.REGISTRY}}" | tr '[:upper:]' '[:lower:]')
# Build platform-specific tags for all target tags
TAGS=""
for TAG in ${{ needs.compute-metadata.outputs.tags }}; do
TAGS="$TAGS -t ${REGISTRY}/${IMAGE_NAME}/operator:${TAG}-${PLATFORM_TAG}"
done
if [ "${PUSH_TO_REGISTRY}" = "true" ]; then
PUSH_OR_LOAD="--push"
else
PUSH_OR_LOAD="--load"
echo "Fork PR build: building image without pushing to registry"
fi
set -x
docker buildx build \
--build-arg GIT_SHA=${GIT_SHA} \
--build-arg VERSION=${VERSION} \
--build-arg GO_VERSION="${GO_VERSION}" \
--build-arg DEBIAN_VERSION=${{ env.DEBIAN_VERSION }} \
--build-arg DISTROLESS_VERSION=${{ needs.fetch-distroless-versions.outputs.distroless-version }} \
--build-arg DISTROLESS_DIGEST_SUFFIX="@${{ needs.fetch-distroless-versions.outputs.distroless-digest }}" \
${PUSH_OR_LOAD} \
--platform "$PLATFORM" \
--provenance=false \
${TAGS@L} \
--metadata-file=metadata.json \
-f ../containers/operator.Dockerfile .
echo "digest=$(cat metadata.json | jq -r .\"containerimage.digest\")" >> $GITHUB_OUTPUT
# Create multi-platform manifest from individual architecture builds
create-manifest:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
needs: [compute-metadata, build-operator]
outputs:
digest: ${{ steps.digest.outputs.digest }}
subject-name: ${{ steps.manifest.outputs.subject-name }}
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Log in to the Container registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
# Create and push multi-platform manifests, then delete platform-specific tags
- name: Create manifests and cleanup
id: manifest
run: |
sudo apt-get update && sudo apt-get install -y jq
# Lowercase for Docker compliance
IMAGE_NAME=$(echo "${{env.IMAGE_NAME}}" | tr '[:upper:]' '[:lower:]')
REGISTRY=$(echo "${{env.REGISTRY}}" | tr '[:upper:]' '[:lower:]')
# Create manifest for each tag combining amd64 and arm64 images
for TAG in ${{ needs.compute-metadata.outputs.tags }}; do
FULL_TAG="${REGISTRY}/${IMAGE_NAME}/operator:${TAG}"
echo "📦 Creating manifest for $FULL_TAG"
docker manifest create $FULL_TAG \
${FULL_TAG}-linux-amd64 \
${FULL_TAG}-linux-arm64
docker manifest push $FULL_TAG
echo "✅ Pushed $FULL_TAG"
done
echo "subject-name=${REGISTRY}/${IMAGE_NAME}/operator" >> $GITHUB_OUTPUT
# Note: Platform-specific tags (e.g., v1.0.0-linux-amd64) are left in registry
# as intermediate artifacts. Users should pull the multi-platform manifest tags.
# GitHub Container Registry doesn't easily support programmatic tag deletion.
echo "✅ Multi-platform manifests created successfully"
- name: Resolve multi-platform manifest digest
id: digest
uses: ./.github/actions/resolve-oci-digest
with:
image: ${{ steps.manifest.outputs.subject-name }}
tag: ${{ needs.compute-metadata.outputs.git-sha }}
- name: Sign GHCR operator image and attach SBOM
if: env.PUSH_TO_REGISTRY == 'true' && startsWith(github.ref, 'refs/tags/operator/')
uses: ./.github/actions/cosign-sign-sbom
with:
subject-name: ${{ steps.manifest.outputs.subject-name }}
subject-digest: ${{ steps.digest.outputs.digest }}
- name: Attest GHCR operator provenance
if: env.PUSH_TO_REGISTRY == 'true' && startsWith(github.ref, 'refs/tags/operator/')
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-name: ${{ steps.manifest.outputs.subject-name }}
subject-digest: ${{ steps.digest.outputs.digest }}
push-to-registry: true
- name: Verify GHCR operator signature and attestations
if: env.PUSH_TO_REGISTRY == 'true' && startsWith(github.ref, 'refs/tags/operator/')
uses: ./.github/actions/cosign-verify-release
with:
subject-name: ${{ steps.manifest.outputs.subject-name }}
subject-digest: ${{ steps.digest.outputs.digest }}
certificate-identity-regexp: ^https://github.com/${{ github.repository }}/\.github/workflows/operator-ci\.yaml@refs/tags/operator/.*$
# Single required check for branch protection. operator-ci, agent-ci,
# and lint-ci all publish a check named `ci-gate` — GitHub composes
# same-named required checks, so every ci-gate that posts must pass.
# lint-ci is the always-runs workflow (no path filter) so a ci-gate
# always appears on every PR; this workflow's ci-gate posts only when
# operator-ci's path filter triggers.
#
# The if: always() + jq result==success pattern is the standard fix
# for GitHub Actions' "skipped == green" pitfall.
#
# needs: only includes jobs that ALWAYS run. upload-coverage skips on
# tag builds and create-manifest skips on fork PRs — including either
# would make the gate fail red on legitimate skips.
ci-gate:
name: ci-gate
needs: [tests, build-operator]
if: always()
runs-on: ubuntu-latest
steps:
- name: Verify all required jobs passed
run: |
results='${{ toJSON(needs) }}'
echo "$results"
echo "$results" | jq -e 'to_entries | all(.value.result == "success")'