From 759dec18d87309e3af379fbed9329538179639ee Mon Sep 17 00:00:00 2001 From: Adrien Langou Date: Mon, 9 Nov 2026 15:45:52 +0100 Subject: [PATCH] feat(release): sign OCI images and Helm charts with cosign Signed-off-by: Adrien Langou --- .agents/skills/release-openshell/SKILL.md | 5 ++ .agents/skills/test-release-canary/SKILL.md | 5 ++ .github/workflows/release-dev.yml | 18 +++++ .github/workflows/release-publish.yml | 15 ++++ docs/security/verify-image-contents.mdx | 20 ++++- tasks/scripts/oci_signing_test.py | 81 +++++++++++++++++++++ 6 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 tasks/scripts/oci_signing_test.py diff --git a/.agents/skills/release-openshell/SKILL.md b/.agents/skills/release-openshell/SKILL.md index aca2a98f63..15830ca5b1 100644 --- a/.agents/skills/release-openshell/SKILL.md +++ b/.agents/skills/release-openshell/SKILL.md @@ -39,6 +39,11 @@ Stable images are saved as OCI archives during the build and pushed under the stable version tags during publication. Their SBOMs are verified before updating `latest`; the qualified prerelease's commit-SHA image tags remain unchanged. +The `release-helm` publication job signs the three runtime images and both OCI +charts by digest with keyless cosign. It requires `id-token: write` and GHCR +write access; signing failures block publication of the GitHub Release assets. +Reruns sign the retained artifacts too. See [OCI signature verification](../../../docs/security/verify-image-contents.mdx#verify-oci-signatures). + If no prerelease exists, agree on the version and source commit, then create and push `vX.Y.Z-pre.1`. Prerelease tag pushes start Pre-release. If the tag was created using `GITHUB_TOKEN`, dispatch Pre-release explicitly on the intended branch: diff --git a/.agents/skills/test-release-canary/SKILL.md b/.agents/skills/test-release-canary/SKILL.md index 7be722c3f8..9fd232b5b3 100644 --- a/.agents/skills/test-release-canary/SKILL.md +++ b/.agents/skills/test-release-canary/SKILL.md @@ -48,6 +48,11 @@ validation lives in the `TypeScript SDK` branch check, including a publish dry-run. The tagged release workflow publishes the package to GitHub Packages; verify that job directly when diagnosing SDK publication failures. +Release Dev signs the three runtime images and both OCI charts with keyless +cosign in its `release-helm` job, including both dev chart versions. A signing +failure fails publication and prevents the automatic canary. The canary itself +does not verify signatures; see [OCI signature verification](../../../docs/security/verify-image-contents.mdx#verify-oci-signatures). + ## Trigger paths The workflow has two triggers: diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 71db45ffb1..216e1417b6 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -555,6 +555,7 @@ jobs: timeout-minutes: 10 permissions: packages: write + id-token: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -567,6 +568,23 @@ jobs: release-kind: dev pin-sha: ${{ github.sha }} + - name: Install cosign + uses: sigstore/cosign-installer@756fe2d284c7292c4e9b62c8a28f1e5629c59532 # v4.1.3 + + - name: Sign OCI artifacts + env: + IMAGE_TAG: ${{ github.sha }} + run: | + set -euo pipefail + for artifact in \ + "gateway:${IMAGE_TAG}" "sandbox:${IMAGE_TAG}" "supervisor:${IMAGE_TAG}" \ + helm-chart:0.0.0-dev openshell-workspace:0.0.0-dev \ + "helm-chart:0.0.0-dev.${IMAGE_TAG}" "openshell-workspace:0.0.0-dev.${IMAGE_TAG}"; do + image="ghcr.io/nvidia/openshell/${artifact}" + digest=$(docker buildx imagetools inspect "${image}" --format '{{.Manifest.Digest}}') + cosign sign --yes "${image%:*}@${digest}" + done + publish-fern-docs: name: Sync and Publish Fern Docs needs: [compute-versions, release-dev, release-helm] diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index ed6e63827c..bcea475a16 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -315,6 +315,7 @@ jobs: timeout-minutes: 10 permissions: packages: write + id-token: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -336,6 +337,20 @@ jobs: app-version: ${{ inputs.semver }} release-kind: public + - name: Install cosign + uses: sigstore/cosign-installer@756fe2d284c7292c4e9b62c8a28f1e5629c59532 # v4.1.3 + + - name: Sign OCI artifacts + env: + VERSION: ${{ inputs.semver }} + run: | + set -euo pipefail + for artifact in gateway sandbox supervisor helm-chart openshell-workspace; do + image="ghcr.io/nvidia/openshell/${artifact}" + digest=$(docker buildx imagetools inspect "${image}:${VERSION}" --format '{{.Manifest.Digest}}') + cosign sign --yes "${image}@${digest}" + done + release: name: Publish Release Assets needs: [assemble, release-helm] diff --git a/docs/security/verify-image-contents.mdx b/docs/security/verify-image-contents.mdx index 86273a4762..0a5c70df97 100644 --- a/docs/security/verify-image-contents.mdx +++ b/docs/security/verify-image-contents.mdx @@ -4,7 +4,7 @@ title: "Verify the Contents of Published OpenShell Images" sidebar-title: "Verify Image Contents" slug: "security/verify-image-contents" -description: "Read the SBOM attestation attached to published gateway and supervisor images to audit what each image contains." +description: "Verify OCI artifact signatures and inspect the SBOMs attached to published OpenShell images." keywords: "Generative AI, Cybersecurity, Supply Chain, SBOM, Container Images" position: 2 --- @@ -15,6 +15,24 @@ Stable releases publish rebuilt images under version tags such as `0.4.3`. Promoting a prerelease preserves its commit-SHA image tags, which continue to identify the images used for qualification. +## Verify OCI Signatures + +Release, Pre-release, and Release Dev sign the `gateway`, `sandbox`, and `supervisor` images and both OCI charts (`helm-chart` and `openshell-workspace`) with [cosign](https://docs.sigstore.dev/quickstart/quickstart-cosign/). Signatures cover the manifest digest, including the multi-platform image index, and use the publishing workflow's GitHub Actions identity. They are stored alongside the artifacts in GHCR. Older releases may not have signatures. + +Use cosign 3 to verify an artifact before using it. Resolve the tag once and verify the digest you intend to deploy: + +```shell +ARTIFACT=ghcr.io/nvidia/openshell/gateway:latest +DIGEST=$(docker buildx imagetools inspect "${ARTIFACT}" --format '{{.Manifest.Digest}}') +cosign verify "${ARTIFACT%:*}@${DIGEST}" \ + --certificate-identity 'https://github.com/NVIDIA/OpenShell/.github/workflows/release-publish.yml@refs/heads/main' \ + --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' +``` + +For a chart, set `ARTIFACT` to `ghcr.io/nvidia/openshell/helm-chart:` or `ghcr.io/nvidia/openshell/openshell-workspace:` without the `oci://` prefix. + +The identity above applies to releases dispatched from `main`. For a prerelease triggered by a tag, use `refs/tags/vX.Y.Z-pre.N`; for a release from a support branch, use that branch's `refs/heads/...` value. Dev artifacts use `release-dev.yml@refs/heads/main`. Always match the expected workflow and ref, as well as the GitHub Actions issuer. + ## Inspect an Image Read a platform's document without pulling the image: diff --git a/tasks/scripts/oci_signing_test.py b/tasks/scripts/oci_signing_test.py new file mode 100644 index 0000000000..72a6e2eb95 --- /dev/null +++ b/tasks/scripts/oci_signing_test.py @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Run with uv run --no-project --with pytest --with pyyaml pytest tasks/scripts/oci_signing_test.py.""" + +import hashlib +import os +import subprocess +from pathlib import Path + +import pytest +import yaml + +WORKFLOWS = Path(__file__).resolve().parents[2] / ".github" / "workflows" +IMAGES = ("gateway", "sandbox", "supervisor") +CHARTS = ("helm-chart", "openshell-workspace") + + +@pytest.mark.parametrize( + ("workflow", "version"), + [ + ("release-publish.yml", "1.2.3"), + ("release-publish.yml", "1.2.3-pre.1"), + ("release-dev.yml", "a" * 40), + ], +) +@pytest.mark.parametrize("failure", ["", "docker", "cosign"]) +def test_sign_published_digests(tmp_path, workflow, version, failure): + job = yaml.safe_load((WORKFLOWS / workflow).read_text())["jobs"]["release-helm"] + assert job["permissions"]["id-token"] == "write" + assert job["permissions"]["packages"] == "write" + step = next(s for s in job["steps"] if s.get("name") == "Sign OCI artifacts") + # Retained artifacts must also be signed when publication is retried. + assert "if" not in step + + refs = [f"{name}:{version}" for name in IMAGES] + chart_versions = ( + ["0.0.0-dev", f"0.0.0-dev.{version}"] + if workflow == "release-dev.yml" + else [version] + ) + refs += [f"{name}:{tag}" for tag in chart_versions for name in CHARTS] + refs = [f"ghcr.io/nvidia/openshell/{ref}" for ref in refs] + signatures = tmp_path / "signatures" + lookups = tmp_path / "lookups" + for command, script in { + "docker": """printf '%s\\n' "$4" >> "$LOOKUPS" +test "$FAILURE" != docker || exit 1 +printf 'sha256:%s\\n' "$(printf '%s' "$4" | sha256sum | cut -d' ' -f1)" +""", + "cosign": """printf '%s\\n' "$*" >> "$SIGNATURES" +test "$FAILURE" != cosign +""", + }.items(): + executable = tmp_path / command + executable.write_text("#!/usr/bin/env bash\nset -euo pipefail\n" + script) + executable.chmod(0o755) + + values = {"${{ inputs.semver }}": version, "${{ github.sha }}": version} + result = subprocess.run( + ["bash", "-euo", "pipefail", "-c", step["run"]], + env={ + **os.environ, + **{key: values[value] for key, value in step["env"].items()}, + "PATH": f"{tmp_path}:{os.environ['PATH']}", + "LOOKUPS": str(lookups), + "SIGNATURES": str(signatures), + "FAILURE": failure, + }, + capture_output=True, + text=True, + check=False, + ) + assert (result.returncode == 0) == (not failure), result.stderr + assert lookups.read_text().splitlines() == (refs[:1] if failure else refs) + signed = signatures.read_text().splitlines() if signatures.exists() else [] + expected_refs = [] if failure == "docker" else refs[:1] if failure else refs + assert signed == [ + f"sign --yes {ref.rsplit(':', 1)[0]}@sha256:{hashlib.sha256(ref.encode()).hexdigest()}" + for ref in expected_refs + ]