Skip to content

Commit fedbc16

Browse files
waleedlatif1claude
andcommitted
feat(supply-chain): sign and attest every published image, and inventory the chart's images
Enterprise security reviews ask for artifacts Sim did not publish: a signature proving who built an image, SLSA provenance describing how, and an SBOM listing what is inside. A new attest-subjects job resolves the tags this run actually published to their digests, and attest-images signs each with cosign keyless signing and attaches provenance and, for single-architecture subjects, a Syft SBOM. All of it is pushed to the registry as referrers, so a mirrored registry carries the attestations with the image. Resolving tags rather than assuming them is what makes the coverage complete. `imagetools create` writes an index even from one manifest, so `:version-amd64` has a different digest from the `:sha-amd64` manifest it wraps, and which tags exist depends on whether the run is a release and whether the latest guard held. A moving alias is taken only when it resolves to this run's index, and that read is retried so registry lag is not mistaken for a deliberate skip. The SBOM is generated from the plain `<sha>-<arch>` manifest rather than the subject digest. Syft resolves an index against the runner's platform, so an arm64-only index fails outright on an amd64 runner — and the plain manifest is identical content, shared by both subjects for that architecture. helm/sim/images.yaml is the set of images a complete install pulls, generated from the rendered chart because an image can be referenced from a template that no values key names. Each entry carries the reference to pull from and the path the chart resolves to under a mirror; those differ, and the second is derived from a second render rather than described as a rule that could drift. The NVIDIA device plugin was hardcoded, so an air-gapped cluster could not redirect it. It now takes its image from `ollama.gpu.devicePlugin.image` through the shared helper, and the chart minor is bumped for the new key. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BwsJTEQRzWJaY4BRCkPZt
1 parent 44d5fec commit fedbc16

10 files changed

Lines changed: 750 additions & 5 deletions

File tree

.github/workflows/ci.yml

Lines changed: 281 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,274 @@ jobs:
611611
"${IMAGE}:${SHA}-amd64" "${IMAGE}:${SHA}-arm64"
612612
fi
613613
614+
# Sign the published images and attach SLSA provenance and an SBOM to each.
615+
#
616+
# This runs after create-ghcr-manifests rather than inside the build because
617+
# buildx's own provenance/sbom attestations stay off (see the note in
618+
# .github/actions/docker-build): the extra manifests they add to an index
619+
# break the `imagetools create` retagging that promote-images depends on.
620+
# Attaching attestations here instead leaves the index itself untouched — they
621+
# are stored as separate referrer manifests that point at it.
622+
#
623+
# Resolve the set of digests that actually got published, so the attestation
624+
# job below covers every tag a customer can pull.
625+
#
626+
# A static list is not enough. `imagetools create` always writes an INDEX, so
627+
# `:<version>-amd64` is a single-entry index whose digest differs from the
628+
# `:<sha>-amd64` manifest it wraps — attesting the manifest leaves the tag
629+
# people actually pin unverifiable. Which tags exist also varies per run:
630+
# version tags only on a release, and the latest tags only when the monotonic
631+
# guard in create-ghcr-manifests passed. Resolving tag -> digest here and
632+
# de-duplicating is what keeps the two in step without hardcoding that logic
633+
# twice.
634+
attest-subjects:
635+
name: Resolve Attestation Subjects
636+
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
637+
timeout-minutes: 10
638+
needs: [create-ghcr-manifests, detect-version]
639+
if: >-
640+
!cancelled() &&
641+
needs.create-ghcr-manifests.result == 'success' &&
642+
needs.detect-version.result == 'success' &&
643+
github.event_name == 'push' && github.ref == 'refs/heads/main'
644+
permissions:
645+
contents: read
646+
packages: read
647+
outputs:
648+
subjects: ${{ steps.resolve.outputs.subjects }}
649+
count: ${{ steps.resolve.outputs.count }}
650+
steps:
651+
- name: Login to GHCR
652+
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
653+
with:
654+
registry: ghcr.io
655+
username: ${{ github.repository_owner }}
656+
password: ${{ secrets.GITHUB_TOKEN }}
657+
658+
- name: Resolve published tags to digests
659+
id: resolve
660+
env:
661+
IS_RELEASE: ${{ needs.detect-version.outputs.is_release }}
662+
VERSION: ${{ needs.detect-version.outputs.version }}
663+
SHA: ${{ github.sha }}
664+
run: |
665+
set -euo pipefail
666+
667+
IMAGES="simstudio migrations realtime pii cron"
668+
669+
# Prints the digest, or nothing when the tag is genuinely absent.
670+
#
671+
# An absent tag and a registry hiccup both make `inspect` fail, and
672+
# treating them alike is how a published image silently ends up
673+
# unsigned while this job still goes green. So: retry, and only report
674+
# "absent" when the registry actually says the manifest is unknown.
675+
# Anything else fails the step.
676+
digest_of() {
677+
local ref="$1" attempt raw err absent=0
678+
for attempt in 1 2 3; do
679+
if raw="$(docker buildx imagetools inspect "$ref" --format '{{json .Manifest}}' 2>/tmp/inspect.err)"; then
680+
printf '%s' "$raw" | jq -r '.digest // empty'
681+
return 0
682+
fi
683+
err="$(cat /tmp/inspect.err)"
684+
# Absence is retried like any other failure: GHCR can report a
685+
# just-published alias as unknown for a moment, and accepting that
686+
# on the first attempt would skip a tag this run did publish.
687+
case "$err" in
688+
*"not found"*|*MANIFEST_UNKNOWN*|*"no such manifest"*|*"NAME_UNKNOWN"*) absent=1 ;;
689+
*) absent=0 ;;
690+
esac
691+
if [ "$attempt" -lt 3 ]; then
692+
sleep "$((attempt * 3))"
693+
fi
694+
done
695+
# Only call it absent if the registry said so on the final attempt.
696+
if [ "$absent" -eq 1 ]; then
697+
return 0
698+
fi
699+
echo "::error::Could not inspect ${ref} after 3 attempts: ${err}" >&2
700+
return 1
701+
}
702+
703+
# Records a subject. `platform` tells the attestation job whether this
704+
# digest is a single-architecture image, which is the only case where
705+
# a Syft SBOM describes what the puller actually gets.
706+
emit() {
707+
jq -nc --arg image "$1" --arg digest "$2" --arg platform "$3" \
708+
'{image: $image, digest: $digest, platform: $platform}' >> /tmp/subjects.jsonl
709+
}
710+
711+
: > /tmp/subjects.jsonl
712+
for name in $IMAGES; do
713+
image="ghcr.io/simstudioai/${name}"
714+
715+
# The sha tags are this run's own output. All three must resolve —
716+
# a missing one means the publish did not complete, not that the tag
717+
# is optional.
718+
seen=""
719+
sha_index=""
720+
for tag in "${SHA}" "${SHA}-amd64" "${SHA}-arm64"; do
721+
digest="$(digest_of "${image}:${tag}")"
722+
if [ -z "$digest" ]; then
723+
echo "::error::${image}:${tag} was not published by this run"
724+
exit 1
725+
fi
726+
case "$tag" in
727+
*-amd64) platform=amd64 ;;
728+
*-arm64) platform=arm64 ;;
729+
*) platform=index; sha_index="$digest" ;;
730+
esac
731+
seen="$seen $digest"
732+
emit "$image" "$digest" "$platform"
733+
done
734+
735+
# A moving alias is only taken when it resolves to the same index
736+
# digest this run published — content identity, which is what a
737+
# digest can prove. create-ghcr-manifests holds the latest tags back when
738+
# its monotonic guard sees a newer commit, and they then still point
739+
# at an older build — attesting those would put this run's signature
740+
# and provenance on an image it did not produce. The per-arch
741+
# aliases are published in the same guarded block as `latest`, so
742+
# that one comparison gates all three.
743+
alias_groups="latest"
744+
if [ "${IS_RELEASE}" = "true" ]; then
745+
alias_groups="${alias_groups} ${VERSION}"
746+
fi
747+
748+
for alias in $alias_groups; do
749+
# A mismatch has two very different causes: the guard deliberately
750+
# held the tag back, or GHCR is still serving the previous digest
751+
# moments after this run wrote it. Re-read before concluding the
752+
# former, or a read landing a second early silently drops three
753+
# subjects from the matrix.
754+
alias_index=""
755+
for alias_attempt in 1 2 3; do
756+
alias_index="$(digest_of "${image}:${alias}")"
757+
[ "$alias_index" = "$sha_index" ] && break
758+
[ "$alias_attempt" -lt 3 ] && sleep 5 || true
759+
done
760+
761+
if [ "$alias_index" != "$sha_index" ]; then
762+
# `latest` is legitimately allowed to lag: the monotonic guard in
763+
# create-ghcr-manifests holds it back when the branch has moved
764+
# on, and it then belongs to an older run that attested it.
765+
if [ "$alias" = "latest" ]; then
766+
echo "Skipping latest* for ${image}: it does not point at this run's index."
767+
continue
768+
fi
769+
# A version tag has no such carve-out. This run published it, so
770+
# a release must not ship a version image nothing has attested.
771+
echo "::error::${image}:${alias} does not resolve to this run's index (${sha_index:-none}); refusing to publish an unattested release image"
772+
exit 1
773+
fi
774+
for tag in "${alias}" "${alias}-amd64" "${alias}-arm64"; do
775+
digest="$(digest_of "${image}:${tag}")"
776+
if [ -z "$digest" ]; then
777+
echo "::error::${image}:${tag} is missing though ${image}:${alias} is current"
778+
exit 1
779+
fi
780+
case " $seen " in *" $digest "*) continue ;; esac
781+
seen="$seen $digest"
782+
case "$tag" in
783+
*-amd64) emit "$image" "$digest" amd64 ;;
784+
*-arm64) emit "$image" "$digest" arm64 ;;
785+
*) emit "$image" "$digest" index ;;
786+
esac
787+
done
788+
done
789+
done
790+
791+
if [ ! -s /tmp/subjects.jsonl ]; then
792+
echo "::error::Resolved no image digests to attest"
793+
exit 1
794+
fi
795+
796+
echo "Resolved $(wc -l < /tmp/subjects.jsonl) distinct subjects:"
797+
cat /tmp/subjects.jsonl
798+
echo "subjects=$(jq -sc . /tmp/subjects.jsonl)" >> "$GITHUB_OUTPUT"
799+
echo "count=$(wc -l < /tmp/subjects.jsonl | tr -d ' ')" >> "$GITHUB_OUTPUT"
800+
801+
# One leg per distinct published digest. Attesting each subject separately is
802+
# also what makes the SBOMs truthful: the amd64 and arm64 images contain
803+
# different packages, and one SBOM attached to the index cannot describe both.
804+
attest-images:
805+
name: Attest Images
806+
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
807+
timeout-minutes: 15
808+
needs: [attest-subjects]
809+
if: >-
810+
!cancelled() &&
811+
needs.attest-subjects.result == 'success'
812+
permissions:
813+
contents: read
814+
packages: write
815+
# Sigstore signs against the runner's OIDC identity; no key material is stored.
816+
id-token: write
817+
attestations: write
818+
strategy:
819+
fail-fast: false
820+
matrix:
821+
include: ${{ fromJSON(needs.attest-subjects.outputs.subjects) }}
822+
823+
steps:
824+
- name: Login to GHCR
825+
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
826+
with:
827+
registry: ghcr.io
828+
username: ${{ github.repository_owner }}
829+
password: ${{ secrets.GITHUB_TOKEN }}
830+
831+
# Skipped for index subjects: Syft resolves an index to one platform, so
832+
# the SBOM it produces would describe amd64 while the index also serves
833+
# arm64. The per-arch subjects below carry an accurate SBOM each, and the
834+
# index still gets a signature and provenance.
835+
#
836+
# Scanned by the `<sha>-<arch>` tag rather than by `matrix.digest`. Half
837+
# the per-arch subjects are single-entry INDEXES (`imagetools create`
838+
# writes an index even from one manifest), and Syft resolves an index
839+
# against the RUNNER's platform — so an arm64-only index fails outright on
840+
# an amd64 runner with "no child with platform linux/arm64". The sha tag is
841+
# the plain manifest that index wraps: identical content, no platform
842+
# resolution, and one pull shared by both subjects instead of two.
843+
- name: Generate SBOM
844+
if: matrix.platform != 'index'
845+
uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2
846+
with:
847+
image: ${{ matrix.image }}:${{ github.sha }}-${{ matrix.platform }}
848+
format: spdx-json
849+
output-file: sbom.spdx.json
850+
# The action's own release upload is for workflows triggered by a
851+
# release; these attach to the image instead.
852+
upload-artifact: false
853+
upload-release-assets: false
854+
855+
- name: Attest SBOM
856+
if: matrix.platform != 'index'
857+
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
858+
with:
859+
subject-name: ${{ matrix.image }}
860+
subject-digest: ${{ matrix.digest }}
861+
sbom-path: sbom.spdx.json
862+
# Stored alongside the image so a mirrored registry carries the
863+
# attestation with it, rather than only being retrievable from GitHub.
864+
push-to-registry: true
865+
866+
- name: Attest build provenance
867+
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
868+
with:
869+
subject-name: ${{ matrix.image }}
870+
subject-digest: ${{ matrix.digest }}
871+
push-to-registry: true
872+
873+
- name: Install Cosign
874+
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
875+
876+
# The attestations above prove how the image was built; this is the plain
877+
# signature that admission controllers (Kyverno, the Sigstore policy
878+
# controller) verify before admitting a pod.
879+
- name: Sign image
880+
run: cosign sign --yes "${{ matrix.image }}@${{ matrix.digest }}"
881+
614882
# Check if docs changed
615883
# Smallest runner on purpose: a depth-2 checkout plus a path filter, no
616884
# install and no build.
@@ -652,11 +920,22 @@ jobs:
652920
name: Create GitHub Release
653921
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
654922
timeout-minutes: 10
655-
needs: [create-ghcr-manifests, detect-version]
656-
# Explicit results: see migrate's comment.
923+
needs: [create-ghcr-manifests, attest-subjects, attest-images, detect-version]
924+
# Explicit results: see migrate's comment. attest-images is a gate, not just
925+
# an ordering edge — a release must not advertise images whose signature or
926+
# attestation failed to publish. The count check is belt and braces: today
927+
# attest-subjects already fails on an empty subject set, so this only bites
928+
# if that guard is ever removed.
929+
#
930+
# Note this gates the GitHub release, not the production deploy: CodePipeline
931+
# fires from the ECR tags moved by promote-images, upstream of this job, and
932+
# only the GHCR mirrors are attested.
657933
if: >-
658934
!cancelled() &&
659935
needs.create-ghcr-manifests.result == 'success' &&
936+
needs.attest-subjects.result == 'success' &&
937+
needs.attest-subjects.outputs.count != '0' &&
938+
needs.attest-images.result == 'success' &&
660939
needs.detect-version.result == 'success' &&
661940
needs.detect-version.outputs.is_release == 'true'
662941
permissions:

.github/workflows/helm.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,19 @@ on:
66
paths:
77
- 'helm/sim/**'
88
- '.github/workflows/helm.yml'
9+
# The image inventory is generated from the chart and checked here, so a
10+
# change to its generator has to run this workflow too.
11+
- 'scripts/generate-image-manifest.ts'
12+
- 'package.json'
913
pull_request:
1014
branches: [main, staging, dev]
1115
paths:
1216
- 'helm/sim/**'
1317
- '.github/workflows/helm.yml'
18+
# The image inventory is generated from the chart and checked here, so a
19+
# change to its generator has to run this workflow too.
20+
- 'scripts/generate-image-manifest.ts'
21+
- 'package.json'
1422

1523
concurrency:
1624
group: helm-${{ github.ref }}
@@ -43,6 +51,13 @@ jobs:
4351
- name: Scheduler parity (docker/crontab vs helm cronjobs)
4452
run: bun run scripts/check-cron-parity.ts
4553

54+
# helm/sim/images.yaml is what an operator mirrors into a disconnected
55+
# registry, so a chart change that adds an image has to update it. Lives
56+
# here rather than in `check:audits` because it renders the chart, and the
57+
# audits job has no Helm.
58+
- name: Image inventory is current
59+
run: bun run images:check
60+
4661
- name: Helm lint
4762
run: helm lint helm/sim --values helm/sim/ci/default-values.yaml
4863

helm/sim/Chart.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ apiVersion: v2
22
name: sim
33
description: A Helm chart for Sim - the open-source AI workspace where teams build, deploy, and manage AI agents
44
type: application
5-
version: 1.8.1
5+
version: 1.9.0
66
appVersion: "v0.8.18"
77
kubeVersion: ">=1.25.0-0"
88
home: https://sim.ai

helm/sim/images.yaml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Generated by `bun run images:generate`. Do not edit this file directly.
2+
#
3+
# Every container image this chart can render with all optional components
4+
# enabled — mirror them into a disconnected registry before installing. Two
5+
# entries are not part of an ordinary install: `busybox` renders only from the
6+
# `helm test` hook, and `simstudioai/copilot` requires enterprise registry
7+
# access. Skip either if you do not use it.
8+
#
9+
# To redirect them, set `global.imageRegistry` to your registry AND
10+
# `global.useRegistryForAllImages: true` — on its own, `imageRegistry` only
11+
# rewrites the `simstudioai/*` images, leaving the third-party ones pointing at
12+
# their public registries. Pin each image's `digest` to what your mirror
13+
# resolved.
14+
#
15+
# Each entry pairs the reference to pull FROM with the path the chart resolves
16+
# to once redirected. Copy `source` to `<your-registry>/<mirror>` — they differ:
17+
# `ghcr.io/` is the default registry and is replaced, while the device plugin's
18+
# `nvcr.io/` is part of its repository and is kept. If your registry cannot nest
19+
# that path, override that one image:
20+
# `ollama.gpu.devicePlugin.image.repository=<your-registry>/nvidia/k8s-device-plugin`
21+
# with global rewriting left on, since the override is used verbatim.
22+
appVersion: v0.8.18
23+
images:
24+
- source: busybox:1.36
25+
mirror: busybox:1.36
26+
- source: curlimages/curl:8.5.0
27+
mirror: curlimages/curl:8.5.0
28+
- source: ghcr.io/simstudioai/copilot:v0.8.18
29+
mirror: simstudioai/copilot:v0.8.18
30+
- source: ghcr.io/simstudioai/migrations:v0.8.18
31+
mirror: simstudioai/migrations:v0.8.18
32+
- source: ghcr.io/simstudioai/pii:v0.8.18
33+
mirror: simstudioai/pii:v0.8.18
34+
- source: ghcr.io/simstudioai/realtime:v0.8.18
35+
mirror: simstudioai/realtime:v0.8.18
36+
- source: ghcr.io/simstudioai/simstudio:v0.8.18
37+
mirror: simstudioai/simstudio:v0.8.18
38+
- source: nvcr.io/nvidia/k8s-device-plugin:v0.18.2
39+
mirror: nvcr.io/nvidia/k8s-device-plugin:v0.18.2
40+
- source: ollama/ollama:0.23.2
41+
mirror: ollama/ollama:0.23.2
42+
- source: otel/opentelemetry-collector-contrib:0.91.0
43+
mirror: otel/opentelemetry-collector-contrib:0.91.0
44+
- source: pgvector/pgvector:pg17
45+
mirror: pgvector/pgvector:pg17
46+
- source: postgres:17-alpine
47+
mirror: postgres:17-alpine
48+
- source: redis:7-alpine
49+
mirror: redis:7-alpine

0 commit comments

Comments
 (0)