Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The canonical file lives at `.claude/CLAUDE.md`. The root-level `AGENTS.md` is a

Skyhook (being renamed to NodeWright) is a Kubernetes-aware package manager for safely modifying host infrastructure at scale. It coordinates the node lifecycle (cordon → drain → apply package → interrupt/reboot → uncordon) as controlled rollouts gated by interruption budgets and deployment policies.

Rename status: the project is transitioning from Skyhook → NodeWright. Public names (CRDs `skyhook.nvidia.com/v1alpha1`, Helm chart `skyhook-operator`, CLI `kubectl skyhook`, namespace `skyhook`) still use `skyhook`. The Go module, however, is already `github.com/NVIDIA/nodewright/operator` — don't "fix" imports back to skyhook.
Rename status: the project is transitioning from Skyhook → NodeWright. Most public names (CRDs `skyhook.nvidia.com/v1alpha1`, CLI `kubectl skyhook`, namespace `skyhook`) still use `skyhook`. Components already moved to `nodewright`: the Go module (`github.com/NVIDIA/nodewright/operator`), the Helm chart (`name: nodewright`, distributed at `oci://ghcr.io/nvidia/nodewright/charts/nodewright`), and the operator image (`ghcr.io/nvidia/nodewright/operator`). The agent image is still at `ghcr.io/nvidia/skyhook/agent` pending its migration. Don't "fix" `nodewright` references back to `skyhook`, and don't preemptively rename what hasn't moved yet.

## Required reading: `docs/` (load every session)

Expand Down
108 changes: 108 additions & 0 deletions .github/actions/cosign-sign-sbom/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# 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.

name: Cosign sign and SBOM attest
description: Sign an OCI subject and attach a CycloneDX SBOM attestation.

inputs:
subject-name:
description: OCI repository without tag or digest.
required: true
subject-digest:
description: OCI digest in sha256:<hex> form.
required: true
sbom-source:
description: Source for Syft SBOM generation. Defaults to subject-name@subject-digest.
required: false
default: ""
syft-version:
description: Syft version used to generate CycloneDX SBOMs.
required: false
default: "v1.38.0"

outputs:
sbom-file:
description: Generated CycloneDX SBOM file.
value: ${{ steps.sbom.outputs.sbom-file }}

runs:
using: composite
steps:
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2

- name: Install Syft
id: syft
uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with:
syft-version: ${{ inputs.syft-version }}

- name: Generate CycloneDX SBOM
id: sbom
shell: bash
env:
SUBJECT_NAME: ${{ inputs.subject-name }}
SUBJECT_DIGEST: ${{ inputs.subject-digest }}
SBOM_SOURCE: ${{ inputs.sbom-source }}
SYFT: ${{ steps.syft.outputs.cmd }}
run: |
set -euo pipefail

if [ -z "${SUBJECT_NAME}" ]; then
echo "::error::subject-name is required"
exit 1
fi
if ! [[ "${SUBJECT_DIGEST}" =~ ^sha256:[a-f0-9]{64}$ ]]; then
echo "::error::subject-digest must be in sha256:<64 hex> form"
exit 1
fi
if [ -z "${SYFT}" ]; then
echo "::error::Syft command was not provided by the installer"
exit 1
fi

subject="${SUBJECT_NAME}@${SUBJECT_DIGEST}"
if [ -z "${SBOM_SOURCE}" ]; then
SBOM_SOURCE="${subject}"
fi

work_dir="${RUNNER_TEMP}/nodewright-sboms"
mkdir -p "${work_dir}"
safe_name="$(printf '%s' "${SUBJECT_NAME}" | tr '/:@' '---' | tr -c '[:alnum:]._-' '-')"
sbom_file="${work_dir}/${safe_name}.cyclonedx.json"

"${SYFT}" "${SBOM_SOURCE}" -o "cyclonedx-json=${sbom_file}"
jq empty "${sbom_file}"
echo "sbom-file=${sbom_file}" >> "${GITHUB_OUTPUT}"

- name: Sign subject
shell: bash
env:
SUBJECT_NAME: ${{ inputs.subject-name }}
SUBJECT_DIGEST: ${{ inputs.subject-digest }}
run: |
set -euo pipefail
cosign sign --yes "${SUBJECT_NAME}@${SUBJECT_DIGEST}"

- name: Attach CycloneDX SBOM attestation
shell: bash
env:
SUBJECT_NAME: ${{ inputs.subject-name }}
SUBJECT_DIGEST: ${{ inputs.subject-digest }}
SBOM_FILE: ${{ steps.sbom.outputs.sbom-file }}
run: |
set -euo pipefail
cosign attest --yes --predicate "${SBOM_FILE}" --type cyclonedx "${SUBJECT_NAME}@${SUBJECT_DIGEST}"
74 changes: 74 additions & 0 deletions .github/actions/cosign-verify-release/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# 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.

name: Cosign verify release subject
description: Verify release signature, CycloneDX SBOM attestation, and SLSA v1 provenance.

inputs:
subject-name:
description: OCI repository without tag or digest.
required: true
subject-digest:
description: OCI digest in sha256:<hex> form.
required: true
certificate-identity-regexp:
description: Expected Fulcio certificate identity regexp.
required: true
certificate-oidc-issuer:
description: Expected Fulcio OIDC issuer.
required: false
default: "https://token.actions.githubusercontent.com"

runs:
using: composite
steps:
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2

- name: Verify release subject
shell: bash
env:
SUBJECT_NAME: ${{ inputs.subject-name }}
SUBJECT_DIGEST: ${{ inputs.subject-digest }}
CERTIFICATE_IDENTITY_REGEXP: ${{ inputs.certificate-identity-regexp }}
CERTIFICATE_OIDC_ISSUER: ${{ inputs.certificate-oidc-issuer }}
run: |
set -euo pipefail

if [ -z "${SUBJECT_NAME}" ]; then
echo "::error::subject-name is required"
exit 1
fi
if ! [[ "${SUBJECT_DIGEST}" =~ ^sha256:[a-f0-9]{64}$ ]]; then
echo "::error::subject-digest must be in sha256:<64 hex> form"
exit 1
fi

subject="${SUBJECT_NAME}@${SUBJECT_DIGEST}"
cosign verify \
--certificate-identity-regexp "${CERTIFICATE_IDENTITY_REGEXP}" \
--certificate-oidc-issuer "${CERTIFICATE_OIDC_ISSUER}" \
"${subject}"
cosign verify-attestation \
--certificate-identity-regexp "${CERTIFICATE_IDENTITY_REGEXP}" \
--certificate-oidc-issuer "${CERTIFICATE_OIDC_ISSUER}" \
--type cyclonedx \
"${subject}"
cosign verify-attestation \
--certificate-identity-regexp "${CERTIFICATE_IDENTITY_REGEXP}" \
--certificate-oidc-issuer "${CERTIFICATE_OIDC_ISSUER}" \
--type https://slsa.dev/provenance/v1 \
"${subject}"
98 changes: 98 additions & 0 deletions .github/actions/resolve-oci-digest/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# 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.

name: Resolve OCI digest
description: Resolve the immutable digest for an OCI image, manifest list, or chart artifact.

inputs:
image:
description: OCI repository without tag or digest.
required: true
tag:
description: OCI tag to resolve.
required: true
attempts:
description: Number of attempts before failing.
required: false
default: "1"
retry-delay-seconds:
description: Delay between attempts.
required: false
default: "15"

outputs:
digest:
description: Resolved digest in sha256:<hex> form.
value: ${{ steps.resolve.outputs.digest }}
subject:
description: Immutable OCI subject reference.
value: ${{ steps.resolve.outputs.subject }}

runs:
using: composite
steps:
- name: Resolve digest
id: resolve
shell: bash
env:
IMAGE: ${{ inputs.image }}
TAG: ${{ inputs.tag }}
ATTEMPTS: ${{ inputs.attempts }}
RETRY_DELAY_SECONDS: ${{ inputs.retry-delay-seconds }}
run: |
set -euo pipefail

if [ -z "${IMAGE}" ]; then
echo "::error::image is required"
exit 1
fi
if [ -z "${TAG}" ]; then
echo "::error::tag is required"
exit 1
fi
if ! [[ "${ATTEMPTS}" =~ ^[0-9]+$ ]] || [ "${ATTEMPTS}" -lt 1 ]; then
echo "::error::attempts must be a positive integer"
exit 1
fi
if ! [[ "${RETRY_DELAY_SECONDS}" =~ ^[0-9]+$ ]]; then
echo "::error::retry-delay-seconds must be a non-negative integer"
exit 1
fi

ref="${IMAGE}:${TAG}"
digest=""
err_file="${RUNNER_TEMP}/resolve-oci-digest.err"
for attempt in $(seq 1 "${ATTEMPTS}"); do
echo "Resolving ${ref} (attempt ${attempt}/${ATTEMPTS})"
set +e
digest="$(docker buildx imagetools inspect "${ref}" --format '{{json .Manifest}}' 2>"${err_file}" | jq -r '.digest // empty')"
status=$?
set -e

if [ "${status}" -eq 0 ] && [[ "${digest}" =~ ^sha256:[a-f0-9]{64}$ ]]; then
echo "digest=${digest}" >> "${GITHUB_OUTPUT}"
echo "subject=${IMAGE}@${digest}" >> "${GITHUB_OUTPUT}"
exit 0
fi

cat "${err_file}" >&2 || true
if [ "${attempt}" -lt "${ATTEMPTS}" ]; then
sleep "${RETRY_DELAY_SECONDS}"
fi
done

echo "::error::failed to resolve digest for ${ref}"
exit 1
50 changes: 39 additions & 11 deletions .github/workflows/agent-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ on:
- agent/**
- '!agent/go/**'
- containers/agent.Dockerfile
- .github/actions/**
- .github/workflows/agent-ci.yaml
push:
branches:
Expand All @@ -33,6 +34,7 @@ on:
- agent/**
- '!agent/go/**'
- containers/agent.Dockerfile
- .github/actions/**
- .github/workflows/agent-ci.yaml
env:
REGISTRY: ghcr.io
Expand Down Expand Up @@ -215,18 +217,27 @@ jobs:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
needs: [compute-metadata, build-agent]
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@v6

- 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
Expand All @@ -246,27 +257,44 @@ jobs:
docker manifest push $FULL_TAG
echo "✅ Pushed $FULL_TAG"
done

# Get digest of the main tag (git sha) for attestation
MAIN_TAG="${REGISTRY}/${IMAGE_NAME}/agent:${{ needs.compute-metadata.outputs.git-sha }}"
DIGEST=$(docker manifest inspect $MAIN_TAG | jq -r '.manifests[0].digest')
echo "digest=$DIGEST" >> $GITHUB_OUTPUT

echo "subject-name=${REGISTRY}/${IMAGE_NAME}/agent" >> $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 }}

# Generate supply chain security attestation for the multi-platform manifest
- name: Generate artifact attestation
if: env.PUSH_TO_REGISTRY == 'true'
uses: actions/attest-build-provenance@v4
- name: Sign GHCR agent image and attach SBOM
if: env.PUSH_TO_REGISTRY == 'true' && startsWith(github.ref, 'refs/tags/agent/')
uses: ./.github/actions/cosign-sign-sbom
with:
subject-name: ${{ steps.manifest.outputs.subject-name }}
subject-digest: ${{ steps.manifest.outputs.digest }}
subject-digest: ${{ steps.digest.outputs.digest }}

- name: Attest GHCR agent provenance
if: env.PUSH_TO_REGISTRY == 'true' && startsWith(github.ref, 'refs/tags/agent/')
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 agent signature and attestations
if: env.PUSH_TO_REGISTRY == 'true' && startsWith(github.ref, 'refs/tags/agent/')
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/agent-ci\.yaml@refs/tags/agent/.*$

operator-agent-tests:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
name: Operator Agent Integration Tests
Expand Down
Loading
Loading