fix(release): repair the release pipeline and sign released artifacts #1751
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Container Build | |
| env: | |
| CONTAINER_TRIGGERS: "/container" | |
| on: | |
| push: | |
| branches: [ main, develop ] | |
| paths: | |
| - 'Dockerfile*' | |
| - 'src/**' | |
| - 'pyproject.toml' | |
| - 'deployment/docker/**' | |
| - 'dev-tools/container/container_build.sh' | |
| - '.github/workflows/dev-containers.yml' | |
| workflow_run: | |
| workflows: ["Unit Tests"] | |
| types: [completed] | |
| branches: [main] | |
| pull_request: | |
| branches: [ main, develop ] | |
| paths: | |
| - 'Dockerfile*' | |
| - 'src/**' | |
| - 'pyproject.toml' | |
| - 'deployment/docker/**' | |
| - 'dev-tools/container/container_build.sh' | |
| - '.github/workflows/dev-containers.yml' | |
| workflow_dispatch: | |
| inputs: | |
| push_images: | |
| description: 'Push images to registry' | |
| required: false | |
| default: false | |
| type: boolean | |
| permissions: | |
| contents: read | |
| packages: read | |
| concurrency: | |
| # Scope per ref so runs on different branches/PRs do not cancel each other. | |
| # Without the ref suffix any container run repo-wide would cancel this one, | |
| # causing PR jobs to show CANCELLED (the red-X on PR #292). | |
| group: container-registry-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| config: | |
| name: Configuration | |
| uses: ./.github/workflows/shared-config.yml | |
| if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' | |
| calculate-tags: | |
| name: Calculate Container Tags | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| needs: config | |
| permissions: | |
| contents: read | |
| outputs: | |
| primary-tags: ${{ steps.tags.outputs.primary-tags }} | |
| python-tags: ${{ steps.tags.outputs.python-tags }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Calculate container tags | |
| id: tags | |
| env: | |
| IS_RELEASE: ${{ needs.config.outputs.is-release }} | |
| run: | | |
| make get-container-tags >> "$GITHUB_OUTPUT" | |
| setup-cache: | |
| name: Setup Build Cache | |
| needs: config | |
| uses: ./.github/workflows/cache-management.yml | |
| with: | |
| # Use "dependencies" so the cache key hashes .project.yml/pyproject.toml/uv.lock | |
| # and produces a warm hit for repeated container builds. The old "build" value | |
| # fell through to the SHA-based fallback arm in cache-management.yml, producing | |
| # a cold key on every run and forcing a full dep reinstall each time. | |
| cache-type: dependencies | |
| cache-key-base: deps | |
| python-version: ${{ needs.config.outputs.default-python-version }} | |
| container-pipeline: | |
| name: Build and Scan Container Images | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| needs: [config, calculate-tags, setup-cache] | |
| strategy: | |
| matrix: | |
| python-version: ${{ fromJSON(needs.config.outputs.python-versions) }} | |
| env: | |
| SCAN_LEVEL: ${{ matrix.python-version == needs.config.outputs.default-python-version && 'full' || 'basic' }} | |
| PUSH_TO_REGISTRY: true | |
| permissions: | |
| contents: read | |
| packages: write | |
| security-events: write | |
| # Required for keyless cosign signing (ambient OIDC token) and | |
| # actions/attest-build-provenance on pushed images. | |
| id-token: write | |
| attestations: write | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 | |
| with: | |
| platforms: ${{ needs.config.outputs.build-platforms }} | |
| - name: Log in to Container Registry | |
| uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 | |
| with: | |
| registry: ${{ needs.config.outputs.container-registry }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Setup Python and UV | |
| uses: ./.github/actions/setup-uv-cached | |
| with: | |
| cache-key: ${{ needs.setup-cache.outputs.cache-key }} | |
| fail-on-cache-miss: false | |
| - name: Build package wheel | |
| # This job builds a wheel to scan in a container image and has no bun/uv | |
| # to compile the SPA (it never shipped one). Skip the UI build. | |
| env: | |
| ORB_SKIP_UI_BUILD: "1" | |
| run: make build | |
| - name: Build and scan container image | |
| id: build | |
| shell: bash | |
| env: | |
| REGISTRY: ${{ needs.config.outputs.container-registry }} | |
| IMAGE_NAME: ${{ needs.config.outputs.container-image }} | |
| VERSION: ${{ needs.config.outputs.package-version }} | |
| PYTHON_VERSION: ${{ matrix.python-version }} | |
| BUILD_PLATFORMS: ${{ needs.config.outputs.build-platforms }} | |
| PACKAGE_NAME_SHORT: ${{ needs.config.outputs.short-name }} | |
| run: | | |
| # Trivy cannot scan a multi-arch manifest — it needs one concrete, | |
| # locally-loaded image. We derive the scan arch from the first entry | |
| # of build-platforms (single source of truth from .project.yml) so | |
| # that if amd64 is ever dropped or reordered the scan follows suit. | |
| SCAN_PLATFORM=$(echo "$BUILD_PLATFORMS" | cut -d, -f1) | |
| # Determine whether to push. On workflow_dispatch the push_images input | |
| # controls it explicitly; for all other triggers push unless it is a PR. | |
| # The original expression `(dispatch && inputs.push_images) || 'true'` | |
| # evaluated to 'true' even when push_images was false because the string | |
| # literal 'true' was always the fallback. The corrected form only sets | |
| # DISPATCH_PUSH=false when the caller explicitly opted out on a dispatch. | |
| if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.push_images }}" == "false" ]]; then | |
| DISPATCH_PUSH="false" | |
| else | |
| DISPATCH_PUSH="true" | |
| fi | |
| if [[ "${{ env.PUSH_TO_REGISTRY }}" == "true" && "${{ github.event_name }}" != "pull_request" && "$DISPATCH_PUSH" == "true" ]]; then | |
| # Build multi-platform image and push to registry | |
| docker buildx build \ | |
| --platform "$BUILD_PLATFORMS" \ | |
| --build-arg "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \ | |
| --build-arg "VERSION=$VERSION" \ | |
| --build-arg "VCS_REF=$(git rev-parse --short HEAD)" \ | |
| --build-arg "PYTHON_VERSION=$PYTHON_VERSION" \ | |
| --build-arg "PACKAGE_NAME_SHORT=$PACKAGE_NAME_SHORT" \ | |
| --provenance=true \ | |
| --sbom=true \ | |
| --metadata-file metadata.json \ | |
| --cache-from type=gha \ | |
| --cache-to type=gha,mode=max \ | |
| --push \ | |
| -t "$REGISTRY/$IMAGE_NAME:$VERSION-python$PYTHON_VERSION" \ | |
| . | |
| # Extract the immutable digest for the signing steps below. | |
| DIGEST=$(jq -r '."containerimage.digest"' metadata.json) | |
| { | |
| echo "digest=${DIGEST}" | |
| echo "image=${REGISTRY}/${IMAGE_NAME}" | |
| echo "pushed=true" | |
| } >> "$GITHUB_OUTPUT" | |
| # Pull back a single-arch image for local Trivy scanning. | |
| # Multi-arch manifests cannot be loaded/scanned; we use the first | |
| # entry of build-platforms (SCAN_PLATFORM) rather than hardcoding amd64. | |
| docker pull --platform "$SCAN_PLATFORM" "$REGISTRY/$IMAGE_NAME:$VERSION-python$PYTHON_VERSION" | |
| else | |
| # Build single platform for scanning only (PR/test builds). | |
| # Use the first entry of build-platforms (SCAN_PLATFORM) so the | |
| # scan arch tracks .project.yml rather than a hardcoded value. | |
| docker buildx build \ | |
| --platform "$SCAN_PLATFORM" \ | |
| --build-arg "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \ | |
| --build-arg "VERSION=$VERSION" \ | |
| --build-arg "VCS_REF=$(git rev-parse --short HEAD)" \ | |
| --build-arg "PYTHON_VERSION=$PYTHON_VERSION" \ | |
| --build-arg "PACKAGE_NAME_SHORT=$PACKAGE_NAME_SHORT" \ | |
| --cache-from type=gha \ | |
| --cache-to type=gha,mode=max \ | |
| --load \ | |
| -t "$REGISTRY/$IMAGE_NAME:$VERSION-python$PYTHON_VERSION" \ | |
| . | |
| echo "pushed=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Install cosign | |
| # Only needed when an image was pushed; skip on PR / local-only builds. | |
| if: steps.build.outputs.pushed == 'true' | |
| uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 | |
| - name: Sign container image (keyless / OIDC) | |
| if: steps.build.outputs.pushed == 'true' | |
| env: | |
| DIGEST: ${{ steps.build.outputs.digest }} | |
| IMAGE: ${{ steps.build.outputs.image }} | |
| run: cosign sign --yes "$IMAGE@$DIGEST" | |
| - name: Attach SLSA provenance attestation | |
| if: steps.build.outputs.pushed == 'true' | |
| uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 | |
| with: | |
| subject-name: ${{ steps.build.outputs.image }} | |
| subject-digest: ${{ steps.build.outputs.digest }} | |
| push-to-registry: true | |
| - name: Run Trivy security scan | |
| uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 | |
| with: | |
| image-ref: "${{ needs.config.outputs.container-registry }}/${{ needs.config.outputs.container-image }}:${{ needs.config.outputs.package-version }}-python${{ matrix.python-version }}" | |
| format: 'sarif' | |
| output: 'trivy-results-py${{ matrix.python-version }}.sarif' | |
| exit-code: '0' | |
| severity: ${{ env.SCAN_LEVEL == 'full' && 'CRITICAL,HIGH,MEDIUM' || 'CRITICAL,HIGH' }} | |
| - name: Upload Trivy scan results | |
| uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 | |
| if: always() | |
| with: | |
| sarif_file: 'trivy-results-py${{ matrix.python-version }}.sarif' | |
| - name: Run Hadolint Dockerfile scan | |
| if: matrix.python-version == needs.config.outputs.default-python-version | |
| uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0 | |
| with: | |
| dockerfile: Dockerfile | |
| format: sarif | |
| output-file: hadolint-dockerfile.sarif | |
| no-fail: true | |
| - name: Upload Hadolint scan results | |
| if: matrix.python-version == needs.config.outputs.default-python-version | |
| uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 | |
| with: | |
| sarif_file: hadolint-dockerfile.sarif | |
| container-manifest: | |
| name: Create Multi-Architecture Manifests | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| needs: [config, calculate-tags, setup-cache, container-pipeline] | |
| # 'release' is not a trigger for this workflow so the disjunct was dead; keep only the main-branch guard. | |
| if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' | |
| permissions: | |
| contents: read | |
| packages: write | |
| steps: | |
| - name: Log in to Container Registry | |
| uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 | |
| with: | |
| registry: ${{ needs.config.outputs.container-registry }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Create primary tag manifests | |
| env: | |
| REGISTRY: ${{ needs.config.outputs.container-registry }} | |
| IMAGE: ${{ needs.config.outputs.container-image }} | |
| VERSION: ${{ needs.config.outputs.package-version }} | |
| DEFAULT_PYTHON: ${{ needs.config.outputs.default-python-version }} | |
| PRIMARY_TAGS: ${{ needs.calculate-tags.outputs.primary-tags }} | |
| PYTHON_TAGS: ${{ needs.calculate-tags.outputs.python-tags }} | |
| run: | | |
| # Create primary tags (latest, X.Y.Z) -> point to default Python | |
| if [[ -n "$PRIMARY_TAGS" ]]; then | |
| IFS=',' read -ra TAGS <<< "$PRIMARY_TAGS" | |
| for tag in "${TAGS[@]}"; do | |
| echo "Creating primary tag: $tag -> $VERSION-python$DEFAULT_PYTHON" | |
| docker buildx imagetools create \ | |
| -t "$REGISTRY/$IMAGE:$tag" \ | |
| "$REGISTRY/$IMAGE:$VERSION-python$DEFAULT_PYTHON" | |
| done | |
| fi | |
| # Create Python variant tags (pythonX.Y) -> point to specific Python latest | |
| if [[ -n "$PYTHON_TAGS" ]]; then | |
| IFS=',' read -ra TAGS <<< "$PYTHON_TAGS" | |
| for tag in "${TAGS[@]}"; do | |
| py_ver=${tag#python} | |
| echo "Creating Python variant tag: $tag -> $VERSION-python$py_ver" | |
| docker buildx imagetools create \ | |
| -t "$REGISTRY/$IMAGE:$tag" \ | |
| "$REGISTRY/$IMAGE:$VERSION-python$py_ver" | |
| done | |
| fi | |
| container-validation: | |
| name: Validate Container Tags | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| needs: [config, calculate-tags, setup-cache, container-manifest] | |
| # 'release' is not a trigger for this workflow so the disjunct was dead; keep only the main-branch guard. | |
| if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' | |
| permissions: | |
| contents: read | |
| packages: read | |
| steps: | |
| - name: Log in to Container Registry | |
| uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 | |
| with: | |
| registry: ${{ needs.config.outputs.container-registry }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Test primary tags | |
| env: | |
| REGISTRY: ${{ needs.config.outputs.container-registry }} | |
| IMAGE: ${{ needs.config.outputs.container-image }} | |
| PRIMARY_TAGS: ${{ needs.calculate-tags.outputs.primary-tags }} | |
| PYTHON_TAGS: ${{ needs.calculate-tags.outputs.python-tags }} | |
| run: | | |
| # Test primary tags can be pulled and work | |
| if [[ -n "$PRIMARY_TAGS" ]]; then | |
| IFS=',' read -ra TAGS <<< "$PRIMARY_TAGS" | |
| for tag in "${TAGS[@]}"; do | |
| echo "Testing primary tag: $tag" | |
| docker pull "$REGISTRY/$IMAGE:$tag" | |
| docker run --rm "$REGISTRY/$IMAGE:$tag" version | |
| echo "Primary tag $tag works" | |
| done | |
| fi | |
| # Test Python variant tags | |
| if [[ -n "$PYTHON_TAGS" ]]; then | |
| IFS=',' read -ra TAGS <<< "$PYTHON_TAGS" | |
| for tag in "${TAGS[@]}"; do | |
| echo "Testing Python variant tag: $tag" | |
| docker pull "$REGISTRY/$IMAGE:$tag" | |
| docker run --rm "$REGISTRY/$IMAGE:$tag" version | |
| echo "Python variant tag $tag works" | |
| done | |
| fi | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 | |
| - name: Test multi-architecture support | |
| env: | |
| REGISTRY: ${{ needs.config.outputs.container-registry }} | |
| IMAGE: ${{ needs.config.outputs.container-image }} | |
| VERSION: ${{ needs.config.outputs.package-version }} | |
| DEFAULT_PYTHON: ${{ needs.config.outputs.default-python-version }} | |
| BUILD_PLATFORMS: ${{ needs.config.outputs.build-platforms }} | |
| run: | | |
| # Use buildx imagetools rather than `docker manifest inspect`: | |
| # imagetools authenticates via the existing docker login session and | |
| # handles both OCI and Docker manifest media types consistently for | |
| # GHCR-hosted images. | |
| REF="$REGISTRY/$IMAGE:$VERSION-python$DEFAULT_PYTHON" | |
| echo "Testing multi-arch manifest for $REF" | |
| INSPECT=$(docker buildx imagetools inspect --raw "$REF") | |
| echo "$INSPECT" | |
| # Derive expected arch list from BUILD_PLATFORMS (single source of truth). | |
| # BUILD_PLATFORMS is a comma-separated list of "os/arch" pairs, e.g. | |
| # "linux/amd64,linux/arm64". Extract the arch component of each pair. | |
| MISSING=0 | |
| IFS=',' read -ra PLATFORMS <<< "$BUILD_PLATFORMS" | |
| for platform in "${PLATFORMS[@]}"; do | |
| arch="${platform##*/}" | |
| if echo "$INSPECT" | jq -e --arg arch "$arch" \ | |
| '.manifests[]?.platform.architecture == $arch' | grep -q true; then | |
| echo "Found architecture: $arch" | |
| else | |
| echo "ERROR: architecture missing from manifest: $arch" | |
| MISSING=1 | |
| fi | |
| done | |
| if [[ "$MISSING" -eq 1 ]]; then | |
| echo "Multi-arch manifest missing one or more expected architectures" | |
| exit 1 | |
| fi | |
| echo "Multi-arch manifest contains all expected architectures" | |