From 2a95ab646fdde5f51b9952ec6d6f9cadf84e5ee1 Mon Sep 17 00:00:00 2001 From: Shiju Date: Fri, 18 Sep 2026 10:33:54 +0530 Subject: [PATCH] feat(release): publish immutable core runtime identity manifest Bind standalone core archives and container images to their producing source commit and workflow run. Verify archive bytes, executable architecture and image executable hashes before assembling and attesting the development release manifest. Preserve same-run retry support while rejecting inconsistent identities. Document the inventory limits, installation workflow and release diagnostics. Related to #2946. Cross-workflow artifact resolution and packaging reuse remain separate work. Signed-off-by: Shiju --- .agents/skills/test-release-canary/SKILL.md | 2 + .agents/skills/watch-github-actions/SKILL.md | 2 + .github/actions/build-docker-image/action.yml | 45 +- .github/workflows/release-dev.yml | 35 ++ architecture/build.md | 2 + docs/about/installation.mdx | 2 + python/release_manifest_test.py | 390 ++++++++++++++++ tasks/scripts/release.py | 419 ++++++++++++++++++ 8 files changed, 894 insertions(+), 3 deletions(-) create mode 100644 python/release_manifest_test.py diff --git a/.agents/skills/test-release-canary/SKILL.md b/.agents/skills/test-release-canary/SKILL.md index 66310ed4c2..9a55ad4c79 100644 --- a/.agents/skills/test-release-canary/SKILL.md +++ b/.agents/skills/test-release-canary/SKILL.md @@ -37,6 +37,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 5d926cc647..f48d1c356a 100644 --- a/.agents/skills/watch-github-actions/SKILL.md +++ b/.agents/skills/watch-github-actions/SKILL.md @@ -132,6 +132,8 @@ not substitute the current `main` tip or the event's older PR base SHA. Merge groups and manual runs use their explicit baseline. Findings are reported by `Reject new high or critical findings`; distinguish those from scanner failures. +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 23dd415d2f..4674cdcae9 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -262,6 +262,7 @@ jobs: needs: - compute-versions - package-binaries + - build-images - build-python-wheel - conformance-integration - feature-specific-integration @@ -282,6 +283,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: @@ -424,6 +438,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 @@ -473,6 +506,7 @@ jobs: name.endsWith('.whl') || name.endsWith('.deb') || name.endsWith('.rpm') || + name === 'openshell-release-manifest.json' || name.endsWith('.snap') ) ); @@ -543,6 +577,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/architecture/build.md b/architecture/build.md index 5ac5ff5f18..cacc48f9ee 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -162,6 +162,8 @@ generated by BuildKit's default Syft scanner. The registry exporter uses OCI media types and `oci-artifact=true`, so each attestation identifies its subject. GHCR exposes these through the image index because it has no referrers API. +The image-producing action also records the exact OCI index and Linux platform digests, source commit, workflow run and staged executable hashes. Release Dev joins those records with checksum-verified standalone core archives and checks that each image's staged executable matches the archive for the same component and platform. It assembles and attests a versioned `openshell-release-manifest.json` only after the complete core matrix validates, before replacing development release assets or promoting image tags, then publishes the manifest alongside the archives. A downstream retry may reuse completed image jobs from the same source and workflow run; another run's identities are rejected. The manifest describes artifact association, not tested protocol compatibility or live deployment health. Release Tag publication remains a separate release path. + Attestations require a registry-backed image index. Local builds therefore keep `--provenance=false`, and Podman builds carry neither attestation. `tasks/scripts/verify-image-sbom.sh` verifies the merged multi-arch tag and runs diff --git a/docs/about/installation.mdx b/docs/about/installation.mdx index 57d8ca6b39..e98cb0a43c 100644 --- a/docs/about/installation.mdx +++ b/docs/about/installation.mdx @@ -20,6 +20,8 @@ The script detects your operating system and installs the OpenShell CLI, standal You can also download release artifacts directly from the [OpenShell GitHub Releases](https://github.com/NVIDIA/OpenShell/releases) page. +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. + ### Install a prerelease Prerelease packages are retained as GitHub Actions artifacts for 90 days and require an authenticated [GitHub CLI](https://cli.github.com/) session. The `pre` alias installs the latest prerelease: 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 78695824c2..e284c51a7b 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__":