Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .agents/skills/release-openshell/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions .agents/skills/test-release-canary/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions .github/workflows/release-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,7 @@ jobs:
timeout-minutes: 10
permissions:
packages: write
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -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]
Expand Down
15 changes: 15 additions & 0 deletions .github/workflows/release-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ jobs:
timeout-minutes: 10
permissions:
packages: write
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -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]
Expand Down
20 changes: 19 additions & 1 deletion docs/security/verify-image-contents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
---
Expand All @@ -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:<version>` or `ghcr.io/nvidia/openshell/openshell-workspace:<version>` 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:
Expand Down
81 changes: 81 additions & 0 deletions tasks/scripts/oci_signing_test.py
Original file line number Diff line number Diff line change
@@ -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
]
Loading