Skip to content

Commit f3d74ba

Browse files
committed
fix(release): repair the release pipeline and sign released artifacts
Release-pipeline fixes: - Attach the DI container on the early-dispatch path so 'orb init' and 'orb templates generate' work on a fresh checkout (they ran before the router attached it), unblocking the OpenAPI-export release step. - Fold the web UI into the 'all' extra (and add monitoring-aws) so 'pip install orb-py[all]' and the published container are fully functional; the container installs [all], and the release install syncs --extra all --no-dev (ui conflicts with the ci/dev groups by design). A unit test guards that 'all' keeps covering every runtime extra. - SBOM generation no longer fails the workflow (so a routine advisory can't block the release chain); a real SPDX SBOM is emitted. - Manual workflow_dispatch can no longer publish to prod PyPI or move the 'latest' container tag (publish/manifest/asset jobs gated to release events). - OpenAPI-export readiness check polls the served endpoint instead of health. - Serial-provider junit/coverage filenames match the Codecov upload pattern so the serial leg's results are counted. Signing & provenance: - GitHub release assets carry a Sigstore build-provenance attestation (keyless via OIDC), uploaded alongside the release. - Container images are signed with cosign (keyless) and ship SLSA build provenance + an SBOM attestation (SLSA Build L2); images are scanned before publish. PyPI publishing keeps its PEP 740 attestations.
1 parent 001b964 commit f3d74ba

12 files changed

Lines changed: 349 additions & 22 deletions

File tree

.github/workflows/dev-containers.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
@@ -134,6 +138,7 @@ jobs:
134138
run: make build
135139

136140
- name: Build and scan container image
141+
id: build
137142
shell: bash
138143
env:
139144
REGISTRY: ${{ needs.config.outputs.container-registry }}
@@ -169,12 +174,23 @@ jobs:
169174
--build-arg "VCS_REF=$(git rev-parse --short HEAD)" \
170175
--build-arg "PYTHON_VERSION=$PYTHON_VERSION" \
171176
--build-arg "PACKAGE_NAME_SHORT=$PACKAGE_NAME_SHORT" \
177+
--provenance=true \
178+
--sbom=true \
179+
--metadata-file metadata.json \
172180
--cache-from type=gha \
173181
--cache-to type=gha,mode=max \
174182
--push \
175183
-t "$REGISTRY/$IMAGE_NAME:$VERSION-python$PYTHON_VERSION" \
176184
.
177185
186+
# Extract the immutable digest for the signing steps below.
187+
DIGEST=$(jq -r '."containerimage.digest"' metadata.json)
188+
{
189+
echo "digest=${DIGEST}"
190+
echo "image=${REGISTRY}/${IMAGE_NAME}"
191+
echo "pushed=true"
192+
} >> "$GITHUB_OUTPUT"
193+
178194
# Pull back a single-arch image for local Trivy scanning.
179195
# Multi-arch manifests cannot be loaded/scanned; we use the first
180196
# entry of build-platforms (SCAN_PLATFORM) rather than hardcoding amd64.
@@ -195,8 +211,29 @@ jobs:
195211
--load \
196212
-t "$REGISTRY/$IMAGE_NAME:$VERSION-python$PYTHON_VERSION" \
197213
.
214+
echo "pushed=false" >> "$GITHUB_OUTPUT"
198215
fi
199216
217+
- name: Install cosign
218+
# Only needed when an image was pushed; skip on PR / local-only builds.
219+
if: steps.build.outputs.pushed == 'true'
220+
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
221+
222+
- name: Sign container image (keyless / OIDC)
223+
if: steps.build.outputs.pushed == 'true'
224+
env:
225+
DIGEST: ${{ steps.build.outputs.digest }}
226+
IMAGE: ${{ steps.build.outputs.image }}
227+
run: cosign sign --yes "$IMAGE@$DIGEST"
228+
229+
- name: Attach SLSA provenance attestation
230+
if: steps.build.outputs.pushed == 'true'
231+
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
232+
with:
233+
subject-name: ${{ steps.build.outputs.image }}
234+
subject-digest: ${{ steps.build.outputs.digest }}
235+
push-to-registry: true
236+
200237
- name: Run Trivy security scan
201238
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
202239
with:

.github/workflows/dev-pypi.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ jobs:
8080
echo "VERSION=${{ needs.config.outputs.package-version }}"
8181
echo "TARGET=testpypi"
8282
} >> "$GITHUB_ENV"
83-
echo "Publishing to: TestPyPI with version: $VERSION"
83+
echo "Publishing to: TestPyPI with version: ${{ needs.config.outputs.package-version }}"
8484
8585
- name: Setup Python and UV
8686
uses: ./.github/actions/setup-uv-cached
@@ -156,6 +156,7 @@ jobs:
156156
name: reports-sbom-${{ needs.config.outputs.package-version }}
157157
path: |
158158
python-sbom-cyclonedx.json
159+
python-sbom.json
159160
python-sbom-spdx.json
160161
retention-days: 180
161162

.github/workflows/prod-release.yml

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ jobs:
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

.github/workflows/semantic-release.yml

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@ jobs:
8585
permissions:
8686
contents: write
8787
packages: write
88-
id-token: write
88+
id-token: write # required by actions/attest-build-provenance (Sigstore OIDC)
89+
attestations: write # required to persist attestation to GitHub's attestations store
8990

9091
steps:
9192
- name: Checkout code
@@ -144,9 +145,47 @@ jobs:
144145
github_token: ${{ steps.cicd.outputs.token }}
145146
tag: ${{ steps.release.outputs.tag }}
146147

148+
- name: Attest build provenance for release artifacts
149+
# python-semantic-release writes wheel + sdist to dist/ before publish-action
150+
# uploads them. We attest the same dist/* files here so every release asset
151+
# has verifiable provenance. The bundle is persisted to GitHub's attestations
152+
# API AND uploaded as a .intoto.jsonl release asset so OpenSSF Scorecard's
153+
# Signed-Releases check — which scans release assets for this extension —
154+
# can detect it and score the release at 10/10.
155+
#
156+
# SHA pinned to actions/attest-build-provenance v4.1.1.
157+
# Verify: https://github.com/actions/attest-build-provenance/releases/tag/v4.1.1
158+
id: attest-artifacts
159+
if: steps.release.outputs.released == 'true'
160+
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
161+
with:
162+
subject-path: 'dist/*.whl, dist/*.tar.gz'
163+
164+
- name: Upload provenance bundle as release asset
165+
# Attach the Sigstore attestation bundle with a .intoto.jsonl suffix so
166+
# OpenSSF Scorecard's releasesHaveProvenance probe can discover it.
167+
if: steps.release.outputs.released == 'true'
168+
env:
169+
GH_TOKEN: ${{ steps.cicd.outputs.token }}
170+
run: |
171+
TAG="${{ steps.release.outputs.tag }}"
172+
VERSION="${{ steps.release.outputs.version }}"
173+
BUNDLE_SRC="${{ steps.attest-artifacts.outputs.bundle-path }}"
174+
BUNDLE_DEST="attestation-v${VERSION}.intoto.jsonl"
175+
cp "$BUNDLE_SRC" "$BUNDLE_DEST"
176+
gh release upload "$TAG" "$BUNDLE_DEST" --clobber
177+
147178
- name: Install ORB
148179
if: steps.release.outputs.released == 'true'
149-
run: uv sync --all-extras --group ci
180+
# This step only boots the server to export the OpenAPI spec, so it needs
181+
# the runtime `all` extra (api + providers + ui) but NOT the ci/dev test
182+
# tooling. --no-dev drops the default `dev` group; without it uv would
183+
# reject `all` (which now includes `ui`) via the ui↔dev conflict.
184+
# ORB_SKIP_UI_BUILD so building the wheel here doesn't invoke the
185+
# (bun-less) SPA build hook.
186+
env:
187+
ORB_SKIP_UI_BUILD: "1"
188+
run: uv sync --extra all --no-dev
150189

151190
- name: Export OpenAPI spec for Go SDK
152191
if: steps.release.outputs.released == 'true'

Dockerfile

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,17 @@ RUN pip install --no-cache-dir uv==0.8.12 \
5252
&& uv venv /opt/venv
5353
ENV PATH="/opt/venv/bin:$PATH"
5454

55-
# Copy pre-built wheel and install dependencies
55+
# Copy pre-built wheel and install with all runtime extras.
56+
#
57+
# [all] = cli + api (fastapi/uvicorn) + sql + monitoring + all-providers
58+
# (aws, k8s) + ui (reflex). The server needs api to serve, and the
59+
# default UIConfig (enabled=True, mode="embedded") makes the entrypoint's
60+
# `orb server start --foreground` import orb.ui.app, which needs reflex.
61+
# The ci/dev test tooling groups are not installed — this is a production image.
5662
COPY dist/*.whl /tmp/
57-
RUN uv pip install --no-cache /tmp/*.whl \
58-
&& rm -rf /tmp/*.whl
63+
RUN WHEEL=$(ls /tmp/*.whl) \
64+
&& uv pip install --no-cache "${WHEEL}[all]" \
65+
&& rm -f /tmp/*.whl
5966

6067
# Copy only runtime files needed
6168
COPY config/ ./config/

0 commit comments

Comments
 (0)