9595 contents : read
9696 packages : write
9797 security-events : write
98+ # Required for keyless cosign signing (ambient OIDC token) and
99+ # actions/attest-build-provenance.
100+ id-token : write
101+ attestations : write
98102
99103 steps :
100104 - name : Checkout code
@@ -119,6 +123,7 @@ jobs:
119123 path : dist/
120124
121125 - name : Build and push container image
126+ id : build
122127 env :
123128 REGISTRY : ${{ needs.config.outputs.container-registry }}
124129 IMAGE_NAME : ${{ needs.config.outputs.container-image }}
@@ -134,20 +139,78 @@ jobs:
134139 --build-arg "VCS_REF=$(git rev-parse --short HEAD)" \
135140 --build-arg "PYTHON_VERSION=$PYTHON_VERSION" \
136141 --build-arg "PACKAGE_NAME_SHORT=$PACKAGE_NAME_SHORT" \
142+ --provenance=true \
143+ --sbom=true \
144+ --metadata-file metadata.json \
137145 --cache-from type=gha \
138146 --cache-to type=gha,mode=max \
139147 --push \
140148 -t "$REGISTRY/$IMAGE_NAME:$VERSION-python$PYTHON_VERSION" \
141149 .
142150
151+ # Extract the immutable digest from the BuildKit metadata output.
152+ # containerimage.digest is the multi-arch index digest (sha256:…).
153+ DIGEST=$(jq -r '."containerimage.digest"' metadata.json)
154+ echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
155+ echo "image=${REGISTRY}/${IMAGE_NAME}" >> "$GITHUB_OUTPUT"
156+
157+ # Trivy cannot scan a multi-arch manifest — pull back a single-arch image.
158+ # Use the first entry of build-platforms (same approach as dev-containers.yml).
159+ SCAN_PLATFORM=$(echo "$BUILD_PLATFORMS" | cut -d, -f1)
160+ docker pull --platform "$SCAN_PLATFORM" "$REGISTRY/$IMAGE_NAME:$VERSION-python$PYTHON_VERSION"
161+
162+ - name : Install cosign
163+ uses : sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
164+
165+ - name : Sign container image (keyless / OIDC)
166+ # Signs the immutable digest — not a mutable tag — using the GitHub OIDC
167+ # ambient credential. The Rekor transparency-log entry records the
168+ # workflow identity, giving consumers a verifiable supply-chain link.
169+ env :
170+ DIGEST : ${{ steps.build.outputs.digest }}
171+ IMAGE : ${{ steps.build.outputs.image }}
172+ run : cosign sign --yes "$IMAGE@$DIGEST"
173+
174+ - name : Attach SLSA provenance attestation
175+ # Generates a SLSA Build L2-conformant provenance attestation signed with
176+ # the GitHub OIDC token and pushes it to the registry alongside the image.
177+ # Complements the cosign signature: signature proves who signed; provenance
178+ # records exactly what was built and how (workflow, inputs, commit SHA).
179+ uses : actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
180+ with :
181+ subject-name : ${{ steps.build.outputs.image }}
182+ subject-digest : ${{ steps.build.outputs.digest }}
183+ push-to-registry : true
184+
185+ - name : Run Trivy security scan
186+ uses : aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
187+ with :
188+ image-ref : " ${{ needs.config.outputs.container-registry }}/${{ needs.config.outputs.container-image }}:${{ needs.config.outputs.package-version }}-python${{ matrix.python-version }}"
189+ format : ' sarif'
190+ output : ' trivy-results-py${{ matrix.python-version }}.sarif'
191+ exit-code : ' 0'
192+ severity : ${{ env.SCAN_LEVEL == 'full' && 'CRITICAL,HIGH,MEDIUM' || 'CRITICAL,HIGH' }}
193+
194+ - name : Upload Trivy scan results
195+ uses : github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
196+ if : always()
197+ with :
198+ sarif_file : ' trivy-results-py${{ matrix.python-version }}.sarif'
199+
143200 prod-manifests :
144201 name : Create Production Manifests
145202 needs : [config, prod-containers]
146203 runs-on : ubuntu-latest
147204 timeout-minutes : 15
205+ # Only repoint `latest` / version manifests on a real GitHub Release.
206+ # Manual workflow_dispatch builds images for testing but must not move prod tags.
207+ if : github.event_name == 'release'
148208 permissions :
149209 contents : read
150210 packages : write
211+ # Required for keyless cosign signing of the manifest-list digests.
212+ id-token : write
213+ attestations : write
151214
152215 steps :
153216 - name : Checkout code
@@ -160,7 +223,11 @@ jobs:
160223 username : ${{ github.actor }}
161224 password : ${{ secrets.GITHUB_TOKEN }}
162225
226+ - name : Install cosign
227+ uses : sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
228+
163229 - name : Create production tag manifests
230+ id : manifests
164231 shell : bash
165232 env :
166233 REGISTRY : ${{ needs.config.outputs.container-registry }}
@@ -183,6 +250,9 @@ jobs:
183250 fi
184251 done < <(make get-container-tags-env)
185252
253+ # Collect all created manifest-list digests so the signing step can sign them.
254+ MANIFEST_REFS=""
255+
186256 # Create primary tags (latest, version)
187257 if [[ -n "${primary_tags:-}" ]]; then
188258 IFS=',' read -ra TAGS <<< "$primary_tags"
@@ -191,6 +261,9 @@ jobs:
191261 docker buildx imagetools create \
192262 -t "$REGISTRY/$IMAGE:$tag" \
193263 "$REGISTRY/$IMAGE:$VERSION-python$DEFAULT_PYTHON"
264+ # Resolve the digest of the newly-created manifest list.
265+ DIGEST=$(docker buildx imagetools inspect --format '{{json .Manifest}}' "$REGISTRY/$IMAGE:$tag" | jq -r '.digest')
266+ MANIFEST_REFS="${MANIFEST_REFS} $REGISTRY/$IMAGE@$DIGEST"
194267 done
195268 fi
196269
@@ -203,14 +276,35 @@ jobs:
203276 docker buildx imagetools create \
204277 -t "$REGISTRY/$IMAGE:$tag" \
205278 "$REGISTRY/$IMAGE:$VERSION-python$py_ver"
279+ DIGEST=$(docker buildx imagetools inspect --format '{{json .Manifest}}' "$REGISTRY/$IMAGE:$tag" | jq -r '.digest')
280+ MANIFEST_REFS="${MANIFEST_REFS} $REGISTRY/$IMAGE@$DIGEST"
206281 done
207282 fi
208283
284+ # Deduplicate refs (multiple tags may resolve to the same digest).
285+ UNIQUE_REFS=$(echo "$MANIFEST_REFS" | tr ' ' '\n' | sort -u | tr '\n' ' ')
286+ echo "manifest-refs=${UNIQUE_REFS}" >> "$GITHUB_OUTPUT"
287+
288+ - name : Sign manifest-list images (keyless / OIDC)
289+ # The per-python images were already signed in prod-containers; signing the
290+ # manifest lists (latest, version, pythonX.Y tags) closes the chain so
291+ # consumers of the convenience tags can also verify a signature.
292+ env :
293+ MANIFEST_REFS : ${{ steps.manifests.outputs.manifest-refs }}
294+ run : |
295+ for ref in $MANIFEST_REFS; do
296+ echo "Signing manifest list: $ref"
297+ cosign sign --yes "$ref"
298+ done
299+
209300 prod-pypi :
210301 name : Publish to PyPI
211302 needs : [config, build-wheel]
212303 runs-on : ubuntu-latest
213304 timeout-minutes : 30
305+ # Only publish to production PyPI on a real GitHub Release.
306+ # Manual dispatch must not push a dev-build version to prod PyPI.
307+ if : github.event_name == 'release'
214308 environment : pypi
215309 permissions :
216310 contents : read
@@ -309,8 +403,14 @@ jobs:
309403 needs : [config, build-wheel, prod-containers, prod-pypi]
310404 runs-on : ubuntu-latest
311405 timeout-minutes : 30
406+ # Only upload assets and create gh release upload on a real GitHub Release.
407+ # Manual dispatch would compute a dev version tag (1.x.y.devN) and fail on
408+ # `gh release upload` because that tag doesn't exist as a release.
409+ if : github.event_name == 'release'
312410 permissions :
313411 contents : write
412+ id-token : write # required by actions/attest-build-provenance (Sigstore OIDC)
413+ attestations : write # required to persist attestation to GitHub's attestations store
314414
315415 steps :
316416 - name : Checkout code
@@ -331,15 +431,42 @@ jobs:
331431 - name : Generate SBOM
332432 run : make sbom-generate
333433
434+ - name : Attest build provenance for release artifacts
435+ # Generates a Sigstore in-toto provenance attestation for the wheel and
436+ # sdist. The bundle is also uploaded to GitHub's attestations API so it
437+ # is queryable via `gh attestation verify`. We additionally attach it as
438+ # a release asset (see next step) so OpenSSF Scorecard's Signed-Releases
439+ # check — which scans release assets for .intoto.jsonl files — can
440+ # discover it and score the release at 10/10.
441+ #
442+ # SHA pinned to actions/attest-build-provenance v4.1.1.
443+ # Verify: https://github.com/actions/attest-build-provenance/releases/tag/v4.1.1
444+ id : attest-artifacts
445+ uses : actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
446+ with :
447+ subject-path : ' dist/*.whl, dist/*.tar.gz'
448+
334449 - name : Upload release assets
335450 env :
336451 GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
337452 run : |
338453 VERSION="${{ needs.config.outputs.package-version }}"
339454 RELEASE_TAG="v$VERSION"
455+
456+ # Upload wheel and sdist
340457 gh release upload "$RELEASE_TAG" dist/*.whl dist/*.tar.gz --clobber
458+
459+ # Upload SBOM files if present
341460 for f in python-sbom-cyclonedx.json python-sbom.json; do
342461 if [[ -f "$f" ]]; then
343462 gh release upload "$RELEASE_TAG" "$f" --clobber
344463 fi
345464 done
465+
466+ # Upload the Sigstore attestation bundle as a .intoto.jsonl release asset
467+ # so OpenSSF Scorecard's Signed-Releases check (which looks for this
468+ # extension on release assets) can detect provenance and score 10/10.
469+ BUNDLE_SRC="${{ steps.attest-artifacts.outputs.bundle-path }}"
470+ BUNDLE_DEST="attestation-v${VERSION}.intoto.jsonl"
471+ cp "$BUNDLE_SRC" "$BUNDLE_DEST"
472+ gh release upload "$RELEASE_TAG" "$BUNDLE_DEST" --clobber
0 commit comments