diff --git a/.agents/skills/test-release-canary/SKILL.md b/.agents/skills/test-release-canary/SKILL.md index 1f6965873c..6dd4bafb6b 100644 --- a/.agents/skills/test-release-canary/SKILL.md +++ b/.agents/skills/test-release-canary/SKILL.md @@ -48,6 +48,8 @@ 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 also publishes an attested `openshell-release-manifest.json` for standalone core runtime archives and container images. The canary does not download or verify that manifest and continues to consume the rolling dev packages, chart and image tags. A passing canary proves only the install and runtime paths it exercises; it does not prove manifest attestation or digest selection. For manifest failures, inspect the producing Release Dev run's image identity, assembly and attestation steps with `watch-github-actions`. + ## Trigger paths The workflow has two triggers: diff --git a/.agents/skills/watch-github-actions/SKILL.md b/.agents/skills/watch-github-actions/SKILL.md index 8b5c491901..8120035800 100644 --- a/.agents/skills/watch-github-actions/SKILL.md +++ b/.agents/skills/watch-github-actions/SKILL.md @@ -151,6 +151,8 @@ the `release-tag-v1` qualification profile. Failed qualification prevents stable publication but still allows pre-release artifacts to publish with the failure recorded. +For core runtime release identity failures, inspect `Record immutable image identity` and `Verify SBOM attestation` in the Build Images jobs, then `Download producing image identities`, `Assemble immutable core runtime manifest` and `Attest core runtime manifest` in Release Dev. Build Images is shared by Branch E2E, Release Dev and Release Tag; only Release Dev assembles and publishes `openshell-release-manifest.json`. Compare the `core-image-identity-*` artifacts' source SHA and workflow run ID with the failing release job. A downstream retry can reuse completed image jobs from the same source and run; identities from a different run are rejected. Assembly also checks archive checksums and matches staged image executable hashes to the corresponding archives, so inspect the failed component and platform before rerunning jobs. Assembly and attestation finish before development release assets are replaced or image tags are promoted; successful canary installation does not verify the manifest. + View logs for a specific run: ```bash diff --git a/.github/actions/build-docker-image/action.yml b/.github/actions/build-docker-image/action.yml index b3b19f2447..8f7022469e 100644 --- a/.github/actions/build-docker-image/action.yml +++ b/.github/actions/build-docker-image/action.yml @@ -24,6 +24,12 @@ inputs: runs: using: composite steps: + - name: Set up release identity tooling + uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0 + with: + version: "0.10.12" + enable-cache: false + - uses: ./.github/actions/setup-buildx with: buildkitd-config: /etc/buildkit/buildkitd.toml @@ -56,6 +62,8 @@ runs: install -Dm0755 artifact/arm64/${BINARY} deploy/docker/.build/prebuilt-binaries/arm64/${BINARY} - name: Build ${{ inputs.component }} image + # Keep both exporters' manifest options identical: Buildx reports one + # image digest, which must also identify the pushed registry object. shell: bash env: COMPONENT: ${{ inputs.component }} @@ -72,15 +80,46 @@ runs: --cache-to type=gha,mode=max,scope=${COMPONENT} \ --provenance=mode=min \ --attest type=sbom \ + --metadata-file artifacts/images/${COMPONENT}-build-metadata.json \ --output type=image,push=true,oci-mediatypes=true,oci-artifact=true \ - --output type=oci,dest=artifacts/images/${COMPONENT}.tar \ + --output type=oci,dest=artifacts/images/${COMPONENT}.tar,oci-mediatypes=true,oci-artifact=true \ . + - name: Record immutable image identity + shell: bash + env: + COMPONENT: ${{ inputs.component }} + run: | + set -euo pipefail + metadata="artifacts/images/${COMPONENT}-build-metadata.json" + digest=$(uv run --no-project --offline --no-python-downloads --python python3 python tasks/scripts/release.py image-build-digest --metadata-file "$metadata") + docker buildx imagetools inspect "ghcr.io/nvidia/openshell/${COMPONENT}@${digest}" --raw > "artifacts/images/${COMPONENT}-index.json" + uv run --no-project --offline --no-python-downloads --python python3 python tasks/scripts/release.py record-image-identity \ + --component "$COMPONENT" \ + --source-sha "$(git rev-parse HEAD)" \ + --run-id "$GITHUB_RUN_ID" \ + --run-attempt "$GITHUB_RUN_ATTEMPT" \ + --metadata-file "$metadata" \ + --index-file "artifacts/images/${COMPONENT}-index.json" \ + --binary-dir artifact \ + --output "image-identities/${COMPONENT}.json" + - name: Verify SBOM attestation shell: bash env: - IMAGE_REF: ghcr.io/nvidia/openshell/${{ inputs.component }}:${{ inputs.image-tag }} - run: tasks/scripts/verify-image-sbom.sh "${IMAGE_REF}" --require-cargo + COMPONENT: ${{ inputs.component }} + run: | + set -euo pipefail + digest=$(uv run --no-project --offline --no-python-downloads --python python3 python tasks/scripts/release.py image-build-digest --metadata-file "artifacts/images/${COMPONENT}-build-metadata.json") + tasks/scripts/verify-image-sbom.sh "ghcr.io/nvidia/openshell/${COMPONENT}@${digest}" --require-cargo + + - name: Upload ${{ inputs.component }} identity + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: core-image-identity-${{ inputs.component }} + path: image-identities/${{ inputs.component }}.json + retention-days: 5 + if-no-files-found: error - name: Upload ${{ inputs.component }} image uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 51ced81703..82de8a37a2 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -272,6 +272,7 @@ jobs: needs: - compute-versions - package-binaries + - build-images - build-python-wheel - conformance-integration - feature-specific-integration @@ -292,6 +293,19 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Download producing image identities + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: core-image-identity-* + path: image-identities/ + merge-multiple: true + + - name: Set up release identity tooling + uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0 + with: + version: "0.10.12" + enable-cache: false + - name: Download all CLI artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -434,6 +448,25 @@ jobs: openshell-supervisor-aarch64-unknown-linux-gnu.tar.gz > openshell-supervisor-checksums-sha256.txt cat openshell-supervisor-checksums-sha256.txt + - name: Assemble immutable core runtime manifest + env: + CARGO_VERSION: ${{ needs.compute-versions.outputs.cargo_version }} + run: | + set -euo pipefail + uv run --no-project --offline --no-python-downloads --python python3 python tasks/scripts/release.py generate-release-manifest \ + --source-sha "$GITHUB_SHA" \ + --run-id "$GITHUB_RUN_ID" \ + --run-attempt "$GITHUB_RUN_ATTEMPT" \ + --cargo-version "$CARGO_VERSION" \ + --release-dir release \ + --image-dir image-identities \ + --output release/openshell-release-manifest.json + + - name: Attest core runtime manifest + uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 + with: + subject-path: release/openshell-release-manifest.json + - name: Generate Homebrew formula run: | set -euo pipefail @@ -483,6 +516,7 @@ jobs: name.endsWith('.whl') || name.endsWith('.deb') || name.endsWith('.rpm') || + name === 'openshell-release-manifest.json' || name.endsWith('.snap') ) ); @@ -553,6 +587,7 @@ jobs: release/openshell-sandbox-checksums-sha256.txt release/openshell-supervisor-checksums-sha256.txt release/openshell-prover-checksums-sha256.txt + release/openshell-release-manifest.json release-helm: name: Release Helm Chart (OCI, dev) diff --git a/docs/about/installation.mdx b/docs/about/installation.mdx index 957c4e2316..9cce5e8546 100644 --- a/docs/about/installation.mdx +++ b/docs/about/installation.mdx @@ -47,6 +47,8 @@ curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | OPENSHELL_VERSION=dev sh ``` +Development builds publish `openshell-release-manifest.json` with an artifact attestation. Its `schema_version: 1` and `inventory_scope: "core-runtime"` describe the standalone CLI, gateway, sandbox and supervisor archives, and gateway/sandbox/supervisor container images. The manifest records the full source commit, Cargo version, workflow run, archive targets and SHA256 checksums, and image index and platform digests. Native installer packages, VM-driver bundles, the prover and SDKs are outside this inventory. Retain the manifest, verify downloaded archive bytes against its checksums, and use image references in the form `@` or the selected platform digest. The development download location can move; a checksum mismatch requires obtaining the matching build instead of silently adopting the new bytes. These identities describe one build and do not establish runtime qualification or upgrade compatibility. + For Kubernetes, select the corresponding Helm chart version. Helm chart versions omit the leading `v` from release tags: ```shell diff --git a/python/release_manifest_test.py b/python/release_manifest_test.py new file mode 100644 index 0000000000..ffd95dfb9e --- /dev/null +++ b/python/release_manifest_test.py @@ -0,0 +1,390 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Exercise release assembly with real synthetic archives and producing-job records.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import io +import json +import subprocess +import sys +import tarfile +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +SCRIPT = Path(__file__).resolve().parents[1] / "tasks/scripts/release.py" +SPEC = importlib.util.spec_from_file_location("release_manifest_tooling", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +release = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = release +SPEC.loader.exec_module(release) +SOURCE = "a" * 40 +VERSION = "0.0.117-dev.177+gaaaaaaaaa" + + +def executable(component: str, target: str) -> bytes: + """Create distinct executable identities with genuine architecture headers.""" + header = bytearray(64) + if "linux" in target: + header[:6] = b"\x7fELF\x02\x01" + header[18:20] = (62 if target.startswith("x86_64") else 183).to_bytes( + 2, "little" + ) + else: + header[:4] = b"\xcf\xfa\xed\xfe" + header[4:8] = (0x0100000C).to_bytes(4, "little") + return bytes(header) + f"{component}/{target}".encode() + + +def write_archive(path: Path, binary: str, content: bytes, *, extra=False) -> None: + """Package a synthetic executable through the real tar archive boundary.""" + with tarfile.open(path, "w:gz") as archive: + member = tarfile.TarInfo(binary) + member.size = len(content) + member.mode = 0o755 + archive.addfile(member, io.BytesIO(content)) + if extra: + archive.addfile(tarfile.TarInfo("../unexpected"), io.BytesIO()) + + +class ReleaseManifestTests(unittest.TestCase): + """Prove producing-job records and archive bytes agree before publication.""" + + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.assets = self.root / "release" + self.identities = self.root / "identities" + self.assets.mkdir() + self.identities.mkdir() + self.output = self.assets / "openshell-release-manifest.json" + for component, (binary, libc, darwin) in release.CORE_ARCHIVES.items(): + targets = [f"x86_64-unknown-linux-{libc}", f"aarch64-unknown-linux-{libc}"] + if darwin: + targets.append("aarch64-apple-darwin") + for target in targets: + write_archive( + self.assets / f"{binary}-{target}.tar.gz", + binary, + executable(component, target), + ) + self.rehash(component) + if component in release.IMAGE_COMPONENTS: + staged = self.root / component / "staged" + for arch, triple_arch in (("amd64", "x86_64"), ("arm64", "aarch64")): + target = f"{triple_arch}-unknown-linux-{libc}" + path = staged / arch / binary + path.parent.mkdir(parents=True) + path.write_bytes(executable(component, target)) + self.write_image(component) + + def rehash(self, component: str) -> None: + binary = release.CORE_ARCHIVES[component][0] + lines = [ + f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n" + for path in sorted(self.assets.glob(f"{binary}-*.tar.gz")) + ] + (self.assets / f"{binary}-checksums-sha256.txt").write_text("".join(lines)) + + def write_image( + self, component: str, *, mutate=None, trailing_newline=False + ) -> None: + folder = self.root / component + descriptors = [ + { + "mediaType": release.OCI_MANIFEST, + "digest": "sha256:" + + hashlib.sha256(f"{component}/{arch}".encode()).hexdigest(), + "size": 123, + "platform": {"os": "linux", "architecture": arch}, + } + for arch in ("amd64", "arm64") + ] + descriptors.append( + { + "mediaType": release.OCI_MANIFEST, + "digest": "sha256:" + "f" * 64, + "platform": {"os": "unknown", "architecture": "unknown"}, + "annotations": {"vnd.docker.reference.type": "attestation-manifest"}, + } + ) + index = { + "schemaVersion": 2, + "mediaType": release.OCI_INDEX, + "manifests": descriptors, + } + if mutate is not None: + mutate(index) + raw = json.dumps(index, separators=(",", ":")).encode() + (folder / "index.json").write_bytes(raw + (b"\n" if trailing_newline else b"")) + (folder / "metadata.json").write_text( + json.dumps( + { + "containerimage.digest": "sha256:" + + hashlib.sha256(raw).hexdigest(), + "containerimage.config.digest": "sha256:" + "e" * 64, + } + ) + ) + release.record_image_identity( + component=component, + source_sha=SOURCE, + run_id="123", + run_attempt="1", + metadata_file=folder / "metadata.json", + index_file=folder / "index.json", + binary_dir=folder / "staged", + output=self.identities / f"{component}.json", + ) + + def generate(self, **changes) -> dict: + args = { + "source_sha": SOURCE, + "run_id": "123", + "run_attempt": "1", + "cargo_version": VERSION, + "release_dir": self.assets, + "image_dir": self.identities, + "output": self.output, + } + args.update(changes) + release.generate_release_manifest(**args) + return json.loads(self.output.read_text()) + + def mutate_identity(self, component: str, change) -> None: + path = self.identities / f"{component}.json" + record = json.loads(path.read_text()) + change(record) + path.write_text(json.dumps(record)) + + def test_complete_manifest_preserves_exact_artifact_and_platform_identity( + self, + ) -> None: + manifest = self.generate() + self.assertEqual(manifest["source_sha"], SOURCE) + self.assertEqual(manifest["cargo_version"], VERSION) + self.assertEqual(manifest["inventory_scope"], "core-runtime") + self.assertEqual(len(manifest["archives"]), 10) + self.assertEqual(len(manifest["images"]), 3) + for image in manifest["images"]: + self.assertEqual(len(image["platforms"]), 2) + for platform in image["platforms"]: + self.assertRegex(platform["digest"], r"^sha256:[0-9a-f]{64}$") + original = self.output.read_bytes() + self.generate() + self.assertEqual(self.output.read_bytes(), original) + + def test_same_run_retry_can_reuse_completed_producing_jobs(self) -> None: + result = self.generate(run_attempt="2") + self.assertEqual(result["run_attempt"], "2") + self.assertTrue(all(image["run_attempt"] == "1" for image in result["images"])) + + def test_altered_archive_rejected_before_manifest_creation(self) -> None: + next(self.assets.glob("openshell-x86_64-*.tar.gz")).write_bytes(b"changed") + with self.assertRaisesRegex(ValueError, "checksum mismatch"): + self.generate() + self.assertFalse(self.output.exists()) + + def test_exact_stable_tag_accepts_actual_development_version_producer(self) -> None: + responses = { + ("rev-parse", "--short=9", "HEAD"): SOURCE[:9], + ("rev-list", "v0.0.116..HEAD", "--count"): "0", + } + with ( + patch.object(release, "_latest_stable_tag", return_value="v0.0.116"), + patch.object( + release, "_git", side_effect=lambda args: responses[tuple(args)] + ), + ): + produced = release._compute_dev_versions() + self.assertEqual(produced.cargo, "0.0.116") + result = self.generate(cargo_version=produced.cargo) + self.assertEqual(result["cargo_version"], produced.cargo) + self.assertEqual(result["source_sha"], SOURCE) + + def test_extended_git_prefix_accepts_only_the_matching_full_source(self) -> None: + produced = release._versions_from_parts( + (0, 0, 116), 177, SOURCE[:12], "v0.0.116" + ) + self.assertEqual( + self.generate(cargo_version=produced.cargo)["source_sha"], SOURCE + ) + wrong = release._versions_from_parts( + (0, 0, 116), 177, "a" * 11 + "b", "v0.0.116" + ) + with self.assertRaisesRegex(ValueError, "version/source"): + self.generate(cargo_version=wrong.cargo) + + def test_missing_archive_rejected(self) -> None: + next(self.assets.glob("openshell-supervisor-aarch64-*.tar.gz")).unlink() + with self.assertRaises(FileNotFoundError): + self.generate() + + def test_checksum_duplicate_rejected(self) -> None: + path = self.assets / "openshell-checksums-sha256.txt" + path.write_text(path.read_text() * 2) + with self.assertRaisesRegex(ValueError, "duplicate"): + self.generate() + + def test_source_run_and_version_mismatch_are_independent_guards(self) -> None: + for field, value in (("source_sha", "b" * 40), ("run_id", "456")): + with self.subTest(field=field): + path = self.identities / "gateway.json" + original = path.read_text() + self.mutate_identity( + "gateway", + lambda record, key=field, item=value: record.update({key: item}), + ) + with self.assertRaisesRegex(ValueError, "another build"): + self.generate() + path.write_text(original) + with self.assertRaisesRegex(ValueError, "version/source"): + self.generate(cargo_version="0.0.117-dev.177+gbbbbbbbbb") + + def test_extra_or_missing_image_record_rejected(self) -> None: + (self.identities / "extra.json").write_text("{}") + with self.assertRaisesRegex(ValueError, "exactly one identity"): + self.generate() + (self.identities / "extra.json").unlink() + (self.identities / "gateway.json").unlink() + with self.assertRaisesRegex(ValueError, "exactly one identity"): + self.generate() + + def test_image_binary_mismatch_rejected_even_with_valid_archive_checksum( + self, + ) -> None: + path = self.assets / "openshell-gateway-x86_64-unknown-linux-gnu.tar.gz" + write_archive( + path, "openshell-gateway", executable("other", "x86_64-unknown-linux-gnu") + ) + self.rehash("gateway") + with self.assertRaisesRegex(ValueError, "staged image binary"): + self.generate() + + def test_wrong_architecture_and_extra_archive_member_rejected(self) -> None: + path = self.assets / "openshell-x86_64-unknown-linux-musl.tar.gz" + write_archive( + path, "openshell", executable("cli", "aarch64-unknown-linux-musl") + ) + self.rehash("cli") + with self.assertRaisesRegex(ValueError, "architecture"): + self.generate() + write_archive( + path, + "openshell", + executable("cli", "x86_64-unknown-linux-musl"), + extra=True, + ) + self.rehash("cli") + with self.assertRaisesRegex(ValueError, "additional archive member"): + self.generate() + + def test_index_digest_mismatch_and_duplicate_platform_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "duplicate"): + self.write_image( + "gateway", + mutate=lambda index: index["manifests"].append(index["manifests"][0]), + ) + self.write_image("gateway", trailing_newline=True) + path = self.root / "gateway" / "index.json" + path.write_bytes(path.read_bytes().replace(b'"size":123', b'"size":124')) + with self.assertRaisesRegex(ValueError, "index bytes"): + release.record_image_identity( + component="gateway", + source_sha=SOURCE, + run_id="123", + run_attempt="1", + metadata_file=self.root / "gateway" / "metadata.json", + index_file=path, + binary_dir=self.root / "gateway" / "staged", + output=self.root / "bad.json", + ) + + def test_image_platform_and_digest_revalidated_at_assembly(self) -> None: + self.mutate_identity( + "gateway", + lambda record: record["platforms"][1].update(architecture="amd64"), + ) + with self.assertRaisesRegex(ValueError, "duplicate platform"): + self.generate() + self.write_image("gateway") + self.mutate_identity( + "gateway", lambda record: record.update(index_digest="dev") + ) + with self.assertRaisesRegex(ValueError, "OCI digest"): + self.generate() + + def test_archive_symlink_and_json_duplicate_key_rejected(self) -> None: + path = self.assets / "openshell-x86_64-unknown-linux-musl.tar.gz" + with tarfile.open(path, "w:gz") as archive: + member = tarfile.TarInfo("openshell") + member.type = tarfile.SYMTYPE + member.linkname = "/outside" + archive.addfile(member) + self.rehash("cli") + with self.assertRaisesRegex(ValueError, "expected one executable"): + self.generate() + duplicate = self.root / "duplicate.json" + duplicate.write_text('{"source_sha":"one","source_sha":"two"}') + with self.assertRaisesRegex(ValueError, "duplicate JSON key"): + release._read_json(duplicate) + + def test_missing_and_unidentified_image_platform_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "both|amd64 and arm64"): + self.write_image("gateway", mutate=lambda index: index["manifests"].pop(1)) + with self.assertRaisesRegex(ValueError, "unexpected"): + self.write_image( + "gateway", mutate=lambda index: index["manifests"][2].pop("annotations") + ) + + def test_assembly_rejects_unknown_schema_fields_and_wrong_variant(self) -> None: + self.mutate_identity("gateway", lambda record: record.update(qualified=True)) + with self.assertRaisesRegex(ValueError, "another build"): + self.generate() + self.write_image("gateway") + self.mutate_identity( + "gateway", lambda record: record["platforms"][0].update(variant="v8") + ) + with self.assertRaisesRegex(ValueError, "unexpected"): + self.generate() + + def test_real_cli_assembles_and_fails_without_emitting_partial_manifest( + self, + ) -> None: + command = [ + sys.executable, + str(SCRIPT), + "generate-release-manifest", + "--source-sha", + SOURCE, + "--run-id", + "123", + "--run-attempt", + "1", + "--cargo-version", + VERSION, + "--release-dir", + str(self.assets), + "--image-dir", + str(self.identities), + "--output", + str(self.output), + ] + success = subprocess.run(command, capture_output=True, text=True) + self.assertEqual(success.returncode, 0, success.stderr) + self.assertEqual(json.loads(self.output.read_text())["source_sha"], SOURCE) + self.output.unlink() + (self.identities / "supervisor.json").unlink() + failure = subprocess.run(command, capture_output=True, text=True) + self.assertNotEqual(failure.returncode, 0) + self.assertFalse(self.output.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tasks/scripts/release.py b/tasks/scripts/release.py index 4d76f13048..c9d1eedbc9 100644 --- a/tasks/scripts/release.py +++ b/tasks/scripts/release.py @@ -6,9 +6,12 @@ from __future__ import annotations import argparse +import hashlib import json import re import subprocess +import tarfile +import tempfile from dataclasses import asdict, dataclass from pathlib import Path @@ -43,6 +46,374 @@ class Versions: _SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$") _RELEASE_TAG_RE = re.compile(r"^[A-Za-z0-9._-]+$") +# This inventory describes the standalone core runtime, not every package or SDK +# shipped by a release. Targets include libc because architecture alone cannot +# distinguish the static sandbox from the dynamically linked supervisor. +CORE_ARCHIVES = { + "cli": ("openshell", "musl", True), + "gateway": ("openshell-gateway", "gnu", True), + "sandbox": ("openshell-sandbox", "musl", False), + "supervisor": ("openshell-supervisor", "gnu", False), +} +IMAGE_COMPONENTS = ("gateway", "sandbox", "supervisor") +IMAGE_REGISTRY = "ghcr.io/nvidia/openshell" +OCI_INDEX = "application/vnd.oci.image.index.v1+json" +OCI_MANIFEST = "application/vnd.oci.image.manifest.v1+json" +MAX_EXECUTABLE_SIZE = 1024 * 1024 * 1024 + + +def _sha256_stream(stream) -> str: + digest = hashlib.sha256() + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def _sha256_path(path: Path) -> str: + with path.open("rb") as stream: + return _sha256_stream(stream) + + +def _unique_json_object(pairs: list[tuple[str, object]]) -> dict: + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate JSON key: {key}") + result[key] = value + return result + + +def _read_json(path: Path) -> dict: + if path.stat().st_size > 1024 * 1024: + raise ValueError(f"{path.name}: identity document exceeds 1 MiB") + value = json.loads(path.read_bytes(), object_pairs_hook=_unique_json_object) + if not isinstance(value, dict): + raise ValueError(f"{path.name}: expected a JSON object") + return value + + +def _identity_text(value: object, pattern: str, field: str) -> str: + if not isinstance(value, str) or re.fullmatch(pattern, value) is None: + raise ValueError(f"invalid {field}") + return value + + +def _source_identity(source_sha: object, run_id: object, run_attempt: object) -> dict: + """Validate producer identity strings supplied by the CLI or decoded JSON.""" + return { + "source_sha": _identity_text(source_sha, r"[0-9a-f]{40}", "source SHA"), + "run_id": _identity_text(run_id, r"[1-9][0-9]*", "workflow run ID"), + "run_attempt": _identity_text( + run_attempt, r"[1-9][0-9]*", "workflow run attempt" + ), + } + + +def _image_digest(value: object) -> str: + return _identity_text(value, r"sha256:[0-9a-f]{64}", "OCI digest") + + +def _write_identity(output: Path, value: dict) -> None: + # Validation finishes before publication. Atomic replacement prevents an + # interrupted writer from leaving a truncated but apparently final document. + output.parent.mkdir(parents=True, exist_ok=True) + temporary = None + try: + with tempfile.NamedTemporaryFile( + mode="w", encoding="utf-8", dir=output.parent, delete=False + ) as stream: + temporary = Path(stream.name) + json.dump(value, stream, indent=2, sort_keys=True) + stream.write("\n") + temporary.replace(output) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def _executable_architecture(header: bytes, target: str) -> None: + if "linux" in target: + expected = 62 if target.startswith("x86_64-") else 183 + if ( + len(header) < 20 + or header[:6] != b"\x7fELF\x02\x01" + or int.from_bytes(header[18:20], "little") != expected + ): + raise ValueError(f"executable architecture does not match {target}") + elif ( + len(header) < 8 + or header[:4] != b"\xcf\xfa\xed\xfe" + or int.from_bytes(header[4:8], "little") != 0x0100000C + ): + raise ValueError(f"executable architecture does not match {target}") + + +def _archive_executable_sha256(path: Path, binary: str, target: str) -> str: + # The packaging job creates one executable at the archive root. Never + # extract a release archive: symlinks, traversal and additional members are + # invalid inputs even if their compressed bytes have a matching checksum. + with tarfile.open(path, mode="r|gz") as archive: + member = archive.next() + if ( + member is None + or member.name != binary + or not member.isfile() + or not member.mode & 0o111 + or not 0 < member.size <= MAX_EXECUTABLE_SIZE + ): + raise ValueError(f"{path.name}: expected one executable named {binary}") + stream = archive.extractfile(member) + if stream is None: + raise ValueError(f"{path.name}: executable is unavailable") + with stream: + header = stream.read(64) + _executable_architecture(header, target) + digest = hashlib.sha256(header) + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + if archive.next() is not None: + raise ValueError(f"{path.name}: unexpected additional archive member") + return digest.hexdigest() + + +def record_image_identity( + *, + component: str, + source_sha: str, + run_id: str, + run_attempt: str, + metadata_file: Path, + index_file: Path, + binary_dir: Path, + output: Path, +) -> None: + """Record a producing image job's immutable index and staged binary identity.""" + identity = _source_identity(source_sha, run_id, run_attempt) + if component not in IMAGE_COMPONENTS: + raise ValueError("unknown image component") + metadata = _read_json(metadata_file) + digest = _image_digest(metadata.get("containerimage.digest")) + index = _read_json(index_file) + raw = index_file.read_bytes() + # Some CLI versions add a display newline. Accept it only when removing + # that terminator recovers the exact producing build's content digest. + if digest[7:] not in { + hashlib.sha256(raw).hexdigest(), + hashlib.sha256(raw.rstrip(b"\r\n")).hexdigest(), + }: + raise ValueError("OCI index bytes do not match producing image digest") + if index.get("schemaVersion") != 2 or index.get("mediaType") != OCI_INDEX: + raise ValueError("expected an OCI image index") + manifests = index.get("manifests") + if not isinstance(manifests, list): + raise ValueError("OCI index has no manifest descriptors") + platforms = {} + for descriptor in manifests: + if ( + not isinstance(descriptor, dict) + or descriptor.get("mediaType") != OCI_MANIFEST + ): + raise ValueError("invalid OCI manifest descriptor") + platform = descriptor.get("platform") + if not isinstance(platform, dict): + raise ValueError("OCI descriptor has no platform") + platform_digest = _image_digest(descriptor.get("digest")) + annotations = descriptor.get("annotations", {}) + if not isinstance(annotations, dict): + raise ValueError("invalid OCI descriptor annotations") + if ( + platform.get("os") == "unknown" + and platform.get("architecture") == "unknown" + and annotations.get("vnd.docker.reference.type") == "attestation-manifest" + ): + # BuildKit embeds provenance/SBOM manifests in the index. These are + # evidence, never extra executable platforms available to a client. + continue + arch = platform.get("architecture") + variant = platform.get("variant", "") + if ( + platform.get("os") != "linux" + or arch not in ("amd64", "arm64") + or variant not in ("", "v8") + or (arch == "amd64" and variant) + or arch in platforms + ): + raise ValueError("unexpected or duplicate executable image platform") + binary = binary_dir / arch / CORE_ARCHIVES[component][0] + target_arch = "x86_64" if arch == "amd64" else "aarch64" + with binary.open("rb") as stream: + _executable_architecture(stream.read(64), f"{target_arch}-unknown-linux") + platforms[arch] = { + "os": "linux", + "architecture": arch, + "digest": platform_digest, + "binary_sha256": _sha256_path(binary), + } + if variant: + platforms[arch]["variant"] = variant + if set(platforms) != {"amd64", "arm64"}: + raise ValueError("image must provide Linux amd64 and arm64") + _write_identity( + output, + { + "schema_version": 1, + **identity, + "component": component, + "repository": f"{IMAGE_REGISTRY}/{component}", + "index_digest": digest, + "platforms": [platforms[key] for key in sorted(platforms)], + }, + ) + + +def _manifest_checksums(path: Path) -> dict[str, str]: + checksums = {} + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + parts = line.split() + if len(parts) != 2: + raise ValueError(f"{path.name}: malformed checksum entry") + digest, name = parts + name = name.removeprefix("*") + _identity_text(digest, r"[0-9a-f]{64}", "archive SHA256") + if Path(name).name != name or name in checksums: + raise ValueError(f"{path.name}: unsafe or duplicate archive name") + checksums[name] = digest + return checksums + + +def generate_release_manifest( + *, + source_sha: str, + run_id: str, + run_attempt: str, + cargo_version: str, + release_dir: Path, + image_dir: Path, + output: Path, +) -> None: + """Validate and publish the complete core-runtime inventory of a dev build.""" + identity = _source_identity(source_sha, run_id, run_attempt) + # A development workflow at an exact stable tag receives a plain version. + # Otherwise Git's abbreviation can exceed nine characters to remain unique; + # the suffix must still identify the full source recorded by producing jobs. + _identity_text( + cargo_version, + r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)" + r"(?:-dev\.[1-9][0-9]*\+g[0-9a-f]{9,40})?", + "development version/source association", + ) + if "+g" in cargo_version and not source_sha.startswith( + cargo_version.rsplit("+g", 1)[1] + ): + raise ValueError("invalid development version/source association") + archives = [] + binary_hashes = {} + for component, (binary, libc, darwin) in CORE_ARCHIVES.items(): + checksums = _manifest_checksums(release_dir / f"{binary}-checksums-sha256.txt") + targets = [f"x86_64-unknown-linux-{libc}", f"aarch64-unknown-linux-{libc}"] + if darwin: + targets.append("aarch64-apple-darwin") + for target in targets: + filename = f"{binary}-{target}.tar.gz" + expected = checksums.get(filename) + if expected is None: + raise ValueError(f"missing checksum for {filename}") + path = release_dir / filename + actual = _sha256_path(path) + if actual != expected: + raise ValueError(f"{filename}: archive checksum mismatch") + executable_hash = _archive_executable_sha256(path, binary, target) + binary_hashes[(component, target)] = executable_hash + archives.append( + { + "component": component, + "target": target, + "filename": filename, + "sha256": actual, + "size_bytes": path.stat().st_size, + "binary_sha256": executable_hash, + } + ) + expected_names = {f"{component}.json" for component in IMAGE_COMPONENTS} + if {path.name for path in image_dir.iterdir()} != expected_names: + raise ValueError("expected exactly one identity file for each core image") + images = [] + for component in IMAGE_COMPONENTS: + image = _read_json(image_dir / f"{component}.json") + if ( + type(image.get("schema_version")) is not int + or image.get("schema_version") != 1 + or set(image) + != { + "schema_version", + "source_sha", + "run_id", + "run_attempt", + "component", + "repository", + "index_digest", + "platforms", + } + or image.get("source_sha") != source_sha + or image.get("run_id") != run_id + or image.get("component") != component + or image.get("repository") != f"{IMAGE_REGISTRY}/{component}" + ): + raise ValueError(f"{component}: image identity belongs to another build") + # A successful producing job from an earlier attempt of this same run + # remains usable when only downstream release assembly is retried. + _source_identity(source_sha, run_id, image.get("run_attempt")) + _image_digest(image.get("index_digest")) + platforms = image.get("platforms") + if not isinstance(platforms, list) or len(platforms) != 2: + raise ValueError(f"{component}: expected both executable platforms") + seen = set() + for platform in platforms: + if not isinstance(platform, dict): + raise ValueError(f"{component}: invalid image platform") + arch = platform.get("architecture") + variant = platform.get("variant", "") + if ( + platform.get("os") != "linux" + or arch not in ("amd64", "arm64") + or arch in seen + or variant not in ("", "v8") + or (arch == "amd64" and variant) + or set(platform) + - { + "os", + "architecture", + "variant", + "digest", + "binary_sha256", + } + ): + raise ValueError(f"{component}: unexpected or duplicate platform") + seen.add(arch) + _image_digest(platform.get("digest")) + triple_arch = "x86_64" if arch == "amd64" else "aarch64" + target = f"{triple_arch}-unknown-linux-{CORE_ARCHIVES[component][1]}" + if platform.get("binary_sha256") != binary_hashes[(component, target)]: + raise ValueError( + f"{component}/{arch}: staged image binary does not match archive" + ) + images.append(image) + _write_identity( + output, + { + "schema_version": 1, + "inventory_scope": "core-runtime", + **identity, + "source_repository": "https://github.com/NVIDIA/OpenShell", + "cargo_version": cargo_version, + "archive_download_base": f"{GITHUB_RELEASE_DOWNLOADS}/dev", + "archives": sorted(archives, key=lambda item: item["filename"]), + "images": images, + }, + ) + def _repo_root() -> Path: return Path(__file__).resolve().parents[2] @@ -586,6 +957,29 @@ def build_parser() -> argparse.ArgumentParser: help="Path to write the generated Formula Ruby file.", ) + digest_parser = sub.add_parser( + "image-build-digest", help="Read the producing Buildx image digest." + ) + digest_parser.add_argument("--metadata-file", type=Path, required=True) + image_parser = sub.add_parser( + "record-image-identity", help="Record an image-producing job's identity." + ) + manifest_parser = sub.add_parser( + "generate-release-manifest", help="Validate the complete dev core inventory." + ) + for identity_parser in (image_parser, manifest_parser): + identity_parser.add_argument("--source-sha", required=True) + identity_parser.add_argument("--run-id", required=True) + identity_parser.add_argument("--run-attempt", required=True) + identity_parser.add_argument("--output", type=Path, required=True) + image_parser.add_argument("--component", choices=IMAGE_COMPONENTS, required=True) + image_parser.add_argument("--metadata-file", type=Path, required=True) + image_parser.add_argument("--index-file", type=Path, required=True) + image_parser.add_argument("--binary-dir", type=Path, required=True) + manifest_parser.add_argument("--cargo-version", required=True) + manifest_parser.add_argument("--release-dir", type=Path, required=True) + manifest_parser.add_argument("--image-dir", type=Path, required=True) + return parser @@ -620,6 +1014,31 @@ def main() -> None: release_dir=args.release_dir, output=args.output, ) + elif args.command == "image-build-digest": + print( + _image_digest(_read_json(args.metadata_file).get("containerimage.digest")) + ) + elif args.command == "record-image-identity": + record_image_identity( + component=args.component, + source_sha=args.source_sha, + run_id=args.run_id, + run_attempt=args.run_attempt, + metadata_file=args.metadata_file, + index_file=args.index_file, + binary_dir=args.binary_dir, + output=args.output, + ) + elif args.command == "generate-release-manifest": + generate_release_manifest( + source_sha=args.source_sha, + run_id=args.run_id, + run_attempt=args.run_attempt, + cargo_version=args.cargo_version, + release_dir=args.release_dir, + image_dir=args.image_dir, + output=args.output, + ) if __name__ == "__main__":