@@ -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 :
0 commit comments