@@ -103,6 +103,10 @@ jobs:
103103 contents : read
104104 packages : write
105105 security-events : write
106+ # Required for keyless cosign signing (ambient OIDC token) and
107+ # actions/attest-build-provenance on pushed images.
108+ id-token : write
109+ attestations : write
106110
107111 steps :
108112 - name : Checkout code
@@ -126,14 +130,23 @@ jobs:
126130 cache-key : ${{ needs.setup-cache.outputs.cache-key }}
127131 fail-on-cache-miss : false
128132
133+ - name : Setup Bun (for UI static bundle)
134+ # Bun is required to compile the Reflex SPA bundle during `make build`.
135+ # Without it the wheel ships no _static/ directory and the container starts
136+ # in embedded mode with a missing SPA — a non-bootable signed image (H2).
137+ # This mirrors the prod-release.yml build-wheel pattern for prod/dev parity.
138+ uses : oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
139+ with :
140+ bun-version : " 1.2.15" # pinned; update intentionally to avoid surprise breakage
141+
129142 - name : Build package wheel
130- # This job builds a wheel to scan in a container image and has no bun/uv
131- # to compile the SPA (it never shipped one). Skip the UI build.
132- env :
133- ORB_SKIP_UI_BUILD : " 1"
143+ # SPA bundle is compiled by the setuptools build hook during `make build`
144+ # now that Bun is on PATH. ORB_SKIP_UI_BUILD is NOT set so the wheel
145+ # ships the _static/ directory — identical to the prod-release.yml wheel.
134146 run : make build
135147
136148 - name : Build and scan container image
149+ id : build
137150 shell : bash
138151 env :
139152 REGISTRY : ${{ needs.config.outputs.container-registry }}
@@ -169,12 +182,23 @@ jobs:
169182 --build-arg "VCS_REF=$(git rev-parse --short HEAD)" \
170183 --build-arg "PYTHON_VERSION=$PYTHON_VERSION" \
171184 --build-arg "PACKAGE_NAME_SHORT=$PACKAGE_NAME_SHORT" \
185+ --provenance=true \
186+ --sbom=true \
187+ --metadata-file metadata.json \
172188 --cache-from type=gha \
173189 --cache-to type=gha,mode=max \
174190 --push \
175191 -t "$REGISTRY/$IMAGE_NAME:$VERSION-python$PYTHON_VERSION" \
176192 .
177193
194+ # Extract the immutable digest for the signing steps below.
195+ DIGEST=$(jq -r '."containerimage.digest"' metadata.json)
196+ {
197+ echo "digest=${DIGEST}"
198+ echo "image=${REGISTRY}/${IMAGE_NAME}"
199+ echo "pushed=true"
200+ } >> "$GITHUB_OUTPUT"
201+
178202 # Pull back a single-arch image for local Trivy scanning.
179203 # Multi-arch manifests cannot be loaded/scanned; we use the first
180204 # entry of build-platforms (SCAN_PLATFORM) rather than hardcoding amd64.
@@ -195,8 +219,29 @@ jobs:
195219 --load \
196220 -t "$REGISTRY/$IMAGE_NAME:$VERSION-python$PYTHON_VERSION" \
197221 .
222+ echo "pushed=false" >> "$GITHUB_OUTPUT"
198223 fi
199224
225+ - name : Install cosign
226+ # Only needed when an image was pushed; skip on PR / local-only builds.
227+ if : steps.build.outputs.pushed == 'true'
228+ uses : sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
229+
230+ - name : Sign container image (keyless / OIDC)
231+ if : steps.build.outputs.pushed == 'true'
232+ env :
233+ DIGEST : ${{ steps.build.outputs.digest }}
234+ IMAGE : ${{ steps.build.outputs.image }}
235+ run : cosign sign --yes "$IMAGE@$DIGEST"
236+
237+ - name : Attach SLSA provenance attestation
238+ if : steps.build.outputs.pushed == 'true'
239+ uses : actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
240+ with :
241+ subject-name : ${{ steps.build.outputs.image }}
242+ subject-digest : ${{ steps.build.outputs.digest }}
243+ push-to-registry : true
244+
200245 - name : Run Trivy security scan
201246 uses : aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
202247 with :
0 commit comments