Skip to content

Build Images Workflow

Reference documentation for the GitHub Actions build-images workflow and the verify-container-images workflow. The build-images workflow builds, tags, and publishes container images for OpenStack services to GHCR (GitHub Container Registry). Each pushed image receives OCI Image Spec annotations, a CycloneDX SBOM, a Sigstore-signed attestation, and a Grype vulnerability scan with SARIF upload to the GitHub Security tab. The verify-container-images workflow runs static verification tests against container infrastructure files (Dockerfiles, workflows, release configs) without requiring Docker.

Repeated inline step sequences are extracted into reusable composite GitHub Actions (.github/actions/setup-docker-registry/, .github/actions/supply-chain-attest/, .github/actions/checkout-service-source/, .github/actions/export-digest/) and standalone CI scripts (hack/ci-merge-manifest.sh, hack/ci-run-unit-tests.sh), reducing duplication across jobs. All jobs produce identical outputs and maintain the same security pipeline. See Reusable Components for details.

File Locations

FilePath
Build Images workflow.github/workflows/build-images.yaml
Verify Container Images workflow.github/workflows/verify-container-images.yaml
Setup Docker Registry action.github/actions/setup-docker-registry/action.yaml
Supply Chain Attest action.github/actions/supply-chain-attest/action.yaml
Checkout Service Source action.github/actions/checkout-service-source/action.yaml
Export Digest action.github/actions/export-digest/action.yaml
Merge Manifest scripthack/ci-merge-manifest.sh
Run Unit Tests scripthack/ci-run-unit-tests.sh

Both workflow files use the .yaml extension and quote the trigger key as "on" to prevent YAML boolean interpretation. They start with the standard SPDX license header (matching ci.yaml).

Trigger Events

The workflow triggers on two events:

EventScopeDescription
pushbranches: [main, stable/**]Runs on every push to main or any stable/** branch (recursive glob)
pull_requestall branchesRuns on every pull request

Push events produce multi-arch images pushed to GHCR. Pull request events produce single-arch images loaded locally for testing (see PR vs Push Behavior).

Fork PRs are not supported. Base images must be pushed to GHCR on every run (because downstream docker-image:// URIs require registry availability), but fork PRs receive a read-only GITHUB_TOKEN that cannot write packages. The workflow detects fork PRs and fails fast with a clear error message.

Permissions

Top-level permissions grant least privilege. Registry write access and attestation permissions are scoped to merge and build jobs only:

yaml
# Top-level (applies to all jobs)
permissions:
  contents: read

# Job-level (merge jobs + build-service-images)
permissions:
  contents: read
  packages: write
  id-token: write
  attestations: write
  security-events: write

# Verification jobs (verify-base-images, verify-service-images)
permissions:
  contents: read
  packages: read

contents: read allows repository checkout. packages: write is granted to build-base-images, build-service-images, build-tempest (for pushing per-platform digests) and to merge-base-images, merge-tempest-image, merge-service-images (for pushing manifest lists). id-token: write enables Sigstore keyless OIDC signing — the GitHub Actions runner requests a short-lived OIDC token bound to the workflow identity, which Sigstore uses to sign the attestation without managing keys. attestations: write grants access to the GitHub Attestations API for storing signed attestations. security-events: write allows uploading Grype vulnerability scan results in SARIF format to the GitHub Security tab. These three permissions are scoped to the merge jobs (merge-base-images, merge-tempest-image, merge-service-images) and to build-service-images and build-tempest (for PR-only Grype scans via supply-chain-attest with scan-mode: image). The verification jobs (verify-base-images, verify-service-images) do not receive id-token, attestations, or security-events permissions — they only need contents: read (for checkout and test scripts) and packages: read (for pulling images from GHCR), following the principle of least privilege.

Concurrency

The workflow uses a concurrency group scoped per-branch per-workflow:

yaml
concurrency:
  group: ${{ github.ref }}-${{ github.workflow }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

For pull requests, pushing new commits cancels any in-progress build for that same PR branch. For pushes to main or stable/**, in-progress runs are not cancelled, ensuring every merge commit produces a complete set of images.

Reusable Components

Repeated inline step sequences are extracted into six composite GitHub Actions and four standalone CI scripts. These components encapsulate patterns that were previously duplicated across multiple jobs, ensuring consistency and reducing the workflow from ~934 lines to under 600 lines. All components follow the repository's CI script and composite action conventions.

setup-docker-registry

Configures Docker Buildx, authenticates to a container registry, and optionally installs cosign for image signing. Replaces the three-step setup sequence (buildx + login + cosign) that was previously inlined in every job.

InputRequiredDefaultDescription
registrynoghcr.ioContainer registry URL
usernameyesRegistry username
passwordyesRegistry password/token
install-cosignno'true'Whether to install cosign

Usage: All 9 jobs that previously inlined docker login + buildx + cosign now call this composite action. Jobs that do not need cosign (build jobs, verification jobs) pass install-cosign: 'false'. Only merge jobs (which run attestation and signing) leave it at the default 'true'.

supply-chain-attest

Runs the full supply chain security pipeline for a container image: CycloneDX SBOM generation, Grype vulnerability scanning, SARIF upload to the GitHub Security tab, SBOM attestation, build provenance attestation, and cosign signing. Replaces the ~65-line inline sequence that was previously duplicated for each image type.

InputRequiredDefaultDescription
image-nameyesBare image name without tag/digest (e.g. ghcr.io/c5c3/python-base)
image-digestyesImage digest (sha256:...)
sbom-output-fileyesSBOM output filename (e.g. sbom-python-base.cyclonedx.json)
grype-categoryyesSARIF category for the GitHub Security tab
scan-modeno'sbom''sbom' for full supply chain (non-PR) or 'image' for scan-only (PR)
image-ref-for-scanno''Full image ref for image-mode scan

Scan modes:

  • sbom mode (push events): Generates SBOM, scans via SBOM, uploads SARIF, creates SBOM attestation, creates build provenance attestation, and signs with cosign. This is the full supply chain pipeline.
  • image mode (PR events): Scans the image directly with Grype and uploads SARIF. No SBOM generation, no attestation, no signing. Provides vulnerability feedback on PRs without the overhead of full attestation.

Usage: python-base, venv-builder, tempest, and service images all call this composite action. Merge jobs use sbom mode; build jobs use image mode for PR-only scans. This replaces the per-image-type inline SBOM + Grype + SARIF + attest + provenance

  • cosign sequences.

checkout-service-source

Resolves the upstream source ref for an OpenStack service from source-refs.yaml, checks out the upstream repository, applies release-specific patches, and runs constraint overrides. Replaces the multi-step source preparation sequence that was previously duplicated (with "MUST stay in sync" comments) between build-service-images and test-service-images.

InputRequiredDefaultDescription
serviceyesOpenStack service name (e.g. keystone)
releaseyesRelease directory name (e.g. 2025.2)
OutputDescription
source-refResolved version string from source-refs.yaml

Internal steps:

  1. Installs yq (SHA-pinned mikefarah/yq)
  2. Reads the version from releases/<release>/source-refs.yaml via yq (fails with ::error:: if not found or null)
  3. Checks out openstack/<service> at the resolved ref into src/<service>
  4. Applies patches from patches/<service>/<release>/*.patch via git apply (skipped if no patches exist, guarded by hashFiles)
  5. Runs scripts/apply-constraint-overrides.sh <release>

Usage: Both build-service-images and test-service-images call this composite action, eliminating the source checkout duplication and the three "MUST stay in sync" comments.

export-digest

Writes an image digest to a staging directory and uploads it as a GitHub Actions artifact. Replaces the inline mkdir + touch + upload-artifact pattern that was previously duplicated for each image type.

InputRequiredDefaultDescription
digestyesImage digest (sha256:...)
artifact-nameyesName for the uploaded artifact
digest-dirno/tmp/digestsDirectory for digest files

Usage: python-base, venv-builder, tempest, and service images all call this composite action after building, replacing inline mkdir/touch/upload-artifact steps.

hack/ci-merge-manifest.sh

Merges per-platform digest files into a multi-arch manifest using docker buildx imagetools create, then inspects the result to extract and output the final manifest digest. Follows the repo's CI-script conventions: shebang, SPDX header, set -euo pipefail, env var interface with ::error:: annotations.

Env varRequiredDefaultDescription
IMAGEyesFull image name without tag (e.g. ghcr.io/c5c3/python-base)
DIGEST_DIRyesPath to directory containing digest files
TAGSyesSpace-separated list of full tag references to apply
INSPECT_TAGnofirst entry in TAGSTag for post-creation digest inspection

Writes digest=sha256:<hex> to $GITHUB_OUTPUT.

Usage: merge-base-images (×2 for python-base and venv-builder), merge-tempest-image, and merge-service-images all call this script via run: with env vars passed through the step's env: block.

hack/ci-run-unit-tests.sh

Runs stestr-based OpenStack service unit tests inside a venv-builder container image. Handles volume mounts for source code, constraints, test excludes, and result collection. Follows the repo's CI-script conventions.

Env varRequiredDefaultDescription
SERVICE_NAMEyesOpenStack service name (e.g. keystone)
SERVICE_VERSIONyesVersion string for PBR PKG-INFO
INSTALL_SPECyespip install spec (e.g. .[ldap] or .)
VENV_BUILDER_IMAGEyesDocker image to run tests in
RELEASEyesRelease directory name (e.g. 2025.2)
WORKSPACE_DIRno$GITHUB_WORKSPACE or pwdRoot workspace directory
OS_TEST_DBAPI_ADMIN_CONNECTIONnooslo.db admin connection string for opportunistic DB tests

Writes results/testresults.subunit to the workspace.

Usage: test-service-images calls this script, replacing the ~50-line inline docker run block. The script can also be run locally with appropriate env vars for debugging failed tests.

Jobs

The workflow defines eleven jobs with a dependency graph:

text
lint-dockerfiles ─┐
prepare ──────────┤
                  └──> build-base-images (matrix: amd64 + arm64)
                         └──> merge-base-images ──> verify-base-images ──┬──> generate-matrix

                         ┌───────────────────────────────────────────────┘

                         ├──> build-tempest (matrix: release × platform)
                         │       └──> merge-tempest-image (push only)

                         ├──> build-service-images (matrix: service × release × platform)
                         │       └──> merge-service-images ──┐
                         │                                   ├──> verify-service-images (push only)
                         └──> test-service-images ───────────┘
                                    └── hack/ci-run-unit-tests.sh (stestr run)

Each platform (linux/amd64 on ubuntu-latest, linux/arm64 on ubuntu-24.04-arm) is built on a native runner and pushed by digest. merge-base-images then assembles the multi-arch manifest list and runs the supply chain security pipeline via the supply-chain-attest composite action. The same pattern applies to service images via build-service-images and merge-service-images, and to Tempest images via build-tempest and merge-tempest-image.

The verify-base-images job validates base image properties (Python version, user UID/GID, PATH, uv version) before service image builds begin. This catches base image regressions before they cascade into service image failures. The test-service-images job runs upstream unit tests for each service via hack/ci-run-unit-tests.sh, in parallel with build-service-images. On push events, the verify-service-images job validates service images pulled from GHCR and gates on merge-service-images and test-service-images. On PRs, the equivalent image verification runs as an inline step within build-service-images because --load makes the image available only on the same runner (ARM64 is excluded on PRs).

All jobs use the setup-docker-registry composite action for Docker Buildx setup, registry authentication, and optional cosign installation, replacing the previously duplicated three-step setup sequence.

build-base-images

Builds the two base images (python-base and venv-builder) per platform on native runners and pushes each single-arch image by digest. These must always be pushed — even on PRs — because downstream service builds reference them via docker-image:// URIs, which require registry availability. The multi-arch manifest list is assembled by the subsequent merge-base-images job.

PropertyValue
runs-on${{ matrix.runner }} (ubuntu-latest for amd64, ubuntu-24.04-arm for arm64)
timeout-minutes30
needs[lint-dockerfiles]
Matrixplatform: [linux/amd64, linux/arm64] × native runner
Push behaviorPushes by digest to GHCR (even on PRs); tags assigned by merge-base-images

Steps:

#StepAction / CommandDetails
1Reject fork PRsShell (conditional)Fails fast with ::error:: if the PR originates from a fork
2Checkoutactions/checkout@v6Checks out the repository
3Normalize image ownerShell scriptOutputs lowercase owner value from ${{ github.repository_owner }} for use in image references
4Prepare platform pair.github/actions/platform-pairConverts linux/amd64linux-amd64 for use in artifact names and cache scopes
5Setup Docker registry.github/actions/setup-docker-registryBuildx + GHCR login (cosign disabled); replaces inline buildx + login steps
6Resolve ubuntu:noble digestShellResolves the upstream base image digest for OCI base-image annotations
7Generate metadata for python-basedocker/metadata-action@v6Produces OCI labels (title, description, licenses, vendor) for python-base
8Build python-basedocker/build-push-action@v7Context: images/python-base, single platform, push-by-digest=true; digest exported as artifact
9Export python-base digest.github/actions/export-digestWrites digest to staging dir and uploads as artifact digests-python-base-<platform-pair>
10Generate metadata for venv-builderdocker/metadata-action@v6Produces OCI labels for venv-builder
11Build venv-builderdocker/build-push-action@v7Context: images/venv-builder, single platform, push-by-digest=true; uses python-base from step 8 by digest
12Export venv-builder digest.github/actions/export-digestWrites digest to staging dir and uploads as artifact digests-venv-builder-<platform-pair>

merge-base-images

Downloads per-platform digests from build-base-images, assembles multi-arch manifest lists, then runs SBOM generation, vulnerability scanning, attestation, and cosign signing on the final manifests.

PropertyValue
runs-onubuntu-latest
timeout-minutes15
needs[build-base-images]
Permissionscontents: read, packages: write, id-token: write, attestations: write, security-events: write

Steps:

#StepAction / CommandDetails
1Checkoutactions/checkout@v6Checks out the repository
2Normalize image ownerShell scriptOutputs lowercase owner value
3Setup Docker registry.github/actions/setup-docker-registryBuildx + GHCR login + cosign
4Download python-base digestsactions/download-artifact@v4Downloads all digests-python-base-* artifacts, merges into /tmp/digests/python-base/
5Create python-base manifesthack/ci-merge-manifest.shAssembles per-platform digests into multi-arch manifest; tags :latest and :${{ github.sha }}; outputs merged manifest digest
6Supply chain attest python-base.github/actions/supply-chain-attestSBOM + Grype scan + SARIF upload + attestation + provenance + cosign sign. Uses scan-mode: sbom on push, image on PR
7Download venv-builder digestsactions/download-artifact@v4Downloads all digests-venv-builder-* artifacts
8Create venv-builder manifesthack/ci-merge-manifest.shSame pattern as step 5 for venv-builder
9Supply chain attest venv-builder.github/actions/supply-chain-attestSame pattern as step 6 for venv-builder

Each base image is tagged with both :latest (mutable convenience tag) and :${{ github.sha }} (immutable commit-pinned tag). The SHA tag provides an auditable mapping from any base image in GHCR back to the commit that produced it.

Outputs:

OutputFormatExample
python-base-imageghcr.io/<owner>/python-base@sha256:<digest>ghcr.io/c5c3/python-base@sha256:abc123...
venv-builder-imageghcr.io/<owner>/venv-builder@sha256:<digest>ghcr.io/c5c3/venv-builder@sha256:def456...

These outputs are consumed by verify-base-images and build-service-images via needs.merge-base-images.outputs.

verify-base-images

Validates that just-assembled base image manifests meet expected properties before service image builds begin. Runs after merge-base-images and blocks build-service-images, forming the dependency chain: build-base-imagesmerge-base-imagesverify-base-imagesbuild-service-images.

PropertyValue
runs-onubuntu-latest
timeout-minutes10
needs[merge-base-images]
Permissionscontents: read, packages: read

Steps:

#StepAction / CommandDetails
1Checkoutactions/checkout@v6Checks out the repository (needed for test scripts)
2Setup Docker registry.github/actions/setup-docker-registryGHCR login (cosign disabled); replaces inline login step
3Pull base imagesShellPulls both python-base and venv-builder by digest from merge-base-images outputs
4Verify python-baseShellRuns verify_python_base.sh with the digest-tagged image ref
5Verify venv-builderShellRuns verify_venv_builder.sh with the digest-tagged image ref

Test scripts executed:

ScriptValidates
tests/container-images/verify_python_base.shPython version, openstack user (UID/GID 42424), PATH includes /opt/openstack/bin, virtualenv at /opt/openstack
tests/container-images/verify_venv_builder.shuv version (from Dockerfile), pip available, virtualenv at /var/lib/openstack

The job receives image references via needs.merge-base-images.outputs (digest-pinned), ensuring the exact manifests that were just assembled are the ones being tested.

build-service-images

Builds service images per platform on native runners and pushes each single-arch image by digest. Depends on merge-base-images for image references and on verify-base-images to ensure base images are valid before building on top of them. The multi-arch manifest list is assembled by merge-service-images.

On pull requests, ARM64 is excluded (only linux/amd64 is built) and the image is loaded locally for inline verification instead of being pushed to GHCR.

PropertyValue
runs-on${{ matrix.runner }} (ubuntu-latest for amd64, ubuntu-24.04-arm for arm64)
timeout-minutes30
needs[merge-base-images, verify-base-images, generate-matrix]
Matrixservice × release × platform × runner (from generate-matrix.build-matrix; ARM64 excluded on PRs)

Steps:

#StepAction / CommandDetails
1Checkoutactions/checkout@v6Checks out this repository
2Checkout service source.github/actions/checkout-service-sourceResolves source ref, clones upstream, applies patches and constraint overrides
3Resolve extra packagesShellReads releases/<release>/extra-packages.yaml via yq to extract pip_extras (comma-joined), pip_packages (space-joined), and apt_packages (space-joined). All three fields tolerate empty values — the Dockerfile handles them via conditional guards.
4Derive tags.github/actions/derive-service-tagsComposite action. Computes image name and all tags (see Tag Schema)
5Prepare platform pair.github/actions/platform-pairConverts linux/amd64linux-amd64 for artifact names and cache scopes
6Setup Docker registry.github/actions/setup-docker-registryBuildx + GHCR login (cosign disabled)
7Generate metadata for service imagedocker/metadata-action@v6Produces OCI labels and overrides version to the upstream release ref via type=raw strategy
8Build service imagedocker/build-push-action@v7Builds with four named build contexts and three build args. Non-PR: push-by-digest=true, digest exported as artifact. PR: load: true, composite tag
9Export service image digest.github/actions/export-digestNon-PR only. Uploads artifact digests-service-<service>-<release>-<platform-pair>
10Supply chain scan (PR).github/actions/supply-chain-attestPR only: scans locally loaded image via Grype (scan-mode: image), uploads SARIF
11Verify service image (PR)Shell (conditional)PR only: runs verify_${{ matrix.service }}.sh with the locally loaded image ref

merge-service-images

Downloads per-platform digests from build-service-images, assembles multi-arch manifest lists, then runs SBOM generation, vulnerability scanning, attestation, and cosign signing. Runs only on push events.

PropertyValue
runs-onubuntu-latest
timeout-minutes30
needs[merge-base-images, build-service-images, generate-matrix]
ifgithub.event_name != 'pull_request'
Matrixservice × release (from generate-matrix.matrix)
Permissionscontents: read, packages: write, id-token: write, attestations: write, security-events: write

Tag derivation uses the same .github/actions/derive-service-tags composite action as build-service-images, ensuring the manifest is assembled under the exact same tags that were computed during the build.

Steps:

#StepAction / CommandDetails
1Checkoutactions/checkout@v6Checks out the repository
2Install yqmikefarah/yq@v4Required by the derive-service-tags composite action
3Normalize image ownerShell scriptOutputs lowercase owner value
4Setup Docker registry.github/actions/setup-docker-registryBuildx + GHCR login + cosign
5Derive tags.github/actions/derive-service-tagsComposite action. Computes image name and all tags
6Download service image digestsactions/download-artifact@v4Downloads all digests-service-<service>-<release>-* artifacts
7Build service image tagsShellAssembles composite + SHA tags (all branches), version + release tags (main only)
8Create and push service image manifesthack/ci-merge-manifest.shAssembles per-platform digests into multi-arch manifest; outputs merged manifest digest
9Supply chain attest service image.github/actions/supply-chain-attestSBOM + Grype scan + SARIF upload + attestation + provenance + cosign sign

Build Contexts:

The service image build passes four named build contexts to resolve FROM and --mount=type=bind,from= directives in the Dockerfile:

Context nameSourcePurpose
python-basedocker-image://<python-base-image>Runtime base image (output already includes @sha256:<digest>)
venv-builderdocker-image://<venv-builder-image>Build stage image (output already includes @sha256:<digest>)
<service>src/<service>Service source tree (upstream checkout)
upper-constraintsreleases/<release>/Release directory containing upper-constraints.txt

Build Arguments:

The service image build passes three build arguments sourced from releases/<release>/extra-packages.yaml by the "Resolve extra packages" step:

Build argSource fieldFormatExample
PIP_EXTRAS<service>.pip_extrasComma-separatedldap,oauth1
PIP_PACKAGES<service>.pip_packagesSpace-separated(empty by default)
EXTRA_APT_PACKAGES<service>.apt_packagesSpace-separatedlibapache2-mod-wsgi-py3 libldap2 libsasl2-2 libxml2

PIP_EXTRAS and PIP_PACKAGES are consumed in the Dockerfile build stage (stage 1). EXTRA_APT_PACKAGES is consumed in the runtime stage (stage 2). See Container Images — extra-packages.yaml for the YAML schema.

This job does not declare outputs. The verify-service-images job derives its own image refs independently via its own matrix strategy.

test-service-images

Runs upstream unit tests for each service inside the venv-builder container. The job checks out the service source at the version specified in source-refs.yaml, applies any patches and constraint overrides, then executes stestr run inside a Docker container built from the venv-builder image. Test results are exported as subunit artifacts. An optional per-service exclude-list (releases/<release>/test-excludes/<service>.txt) skips known-failing tests via stestr run --exclude-list.

This job runs in parallel with build-service-images — both depend on merge-base-images and verify-base-images, but not on each other. The verify-service-images job gates on both.

PropertyValue
runs-onubuntu-latest
timeout-minutes60
needs[merge-base-images, verify-base-images, generate-matrix]
Permissionscontents: read, packages: read
Matrixservice × release (from generate-matrix.matrix)

Steps:

#StepAction / CommandDetails
1Checkoutactions/checkout@v6Checks out the repository
2Checkout service source.github/actions/checkout-service-sourceResolves source ref, clones upstream, applies patches and constraint overrides; eliminates "MUST stay in sync" duplication with build-service-images
3Resolve pip extrasShellReads pip_extras from extra-packages.yaml to construct the install spec (e.g. .[ldap] or .)
4Setup Docker registry.github/actions/setup-docker-registryGHCR login (cosign disabled); authenticates to pull venv-builder image
5Run testshack/ci-run-unit-tests.shRuns stestr-based unit tests inside the venv-builder container via env var interface; replaces ~50-line inline docker run block
6Upload test resultsactions/upload-artifact@v7Always runs. Uploads results/testresults.subunit with 30-day retention

Test exclude-list:

If releases/<release>/test-excludes/<service>.txt exists, stestr uses it as --exclude-list to skip tests matching the regex patterns in the file. The file follows stestr exclude-list format: blank lines are ignored, # lines are comments, all other lines are regex patterns matching test IDs to skip. See releases/2025.2/test-excludes/keystone.txt for an example.

Test Coverage:

The verify_build_images_workflow.sh script validates test-service-images job structure and steps:

TestValidates
test_five_jobs_definedAll five jobs (build-base-images, verify-base-images, build-service-images, test-service-images, verify-service-images) exist
test_test_service_images_job_structureruns-on: ubuntu-latest, timeout-minutes: 60, contents: read, packages: read, no id-token, attestations, or security-events
test_test_service_images_has_matrixMatrix includes service: keystone and release: 2025.2, with fail-fast: false
test_test_service_images_depends_on_baseneeds array contains build-base-images and verify-base-images
test_test_service_images_uses_venv_builder_outputSteps reference needs.build-base-images.outputs.venv-builder-image
test_test_service_images_source_ref_stepSource-ref step uses yq to read source-refs.yaml with null/empty guard
test_test_service_images_checkout_service_sourceChecks out upstream service repo at correct ref and path
test_test_service_images_apply_patchesConditional patch application step with hashFiles guard
test_test_service_images_constraint_overridesConstraint overrides step references apply-constraint-overrides.sh
test_test_service_images_run_tests_volumesRun tests step mounts service source, constraints, test-excludes, and results volumes
test_test_service_images_run_tests_stestrpip install with stestr, stestr init, and stestr run
test_test_service_images_exclude_list--exclude-list included only when service-specific exclusion file exists
test_test_service_images_subunit_outputstestr last --subunit exports results to testresults.subunit
test_test_service_images_upload_artifactsactions/upload-artifact step for subunit output with if: always() and 30-day retention
test_test_service_images_artifact_nameArtifact name includes matrix.service and matrix.release for disambiguation
test_test_service_images_env_varsrun: blocks use env: for matrix values, not direct ${{ matrix.* }} interpolation
test_test_service_images_docker_runRun tests uses docker run with VENV_BUILDER_IMAGE
test_test_service_images_feature_commentWorkflow contains the expected feature comment
test_verify_service_images_depends_on_service_imagesverify-service-images needs includes both build-service-images and test-service-images
test_timeout_minutes_on_all_jobsAll jobs including test-service-images have timeout-minutes set
test_runs_on_ubuntu_latestAll jobs including test-service-images use runs-on: ubuntu-latest
test_matrix_jobs_fail_fast_falseAll matrix jobs including test-service-images have fail-fast: false

The verify_release_config.sh script validates test-excludes file structure:

TestValidates
test_test_excludes_file_formattest-excludes/keystone.txt contains valid stestr exclude-list format
test_test_excludes_directory_structureAll files in test-excludes/ are .txt and named after services in source-refs.yaml
test_test_excludes_files_match_servicesEach filename (sans .txt) corresponds to a key in source-refs.yaml

verify-service-images

Validates that built service images are functional by running verify_${{ matrix.service }}.sh. This job replaces the former smoke-test job and runs only on push events (when images are in GHCR). It uses its own matrix strategy matching build-service-images to test every service independently.

On PRs, the equivalent verification runs as an inline step within build-service-images (step 11 above) because --load makes the image available only on the same runner.

PropertyValue
runs-onubuntu-latest
timeout-minutes10
needs[merge-service-images, test-service-images, generate-matrix]
ifgithub.event_name != 'pull_request'
Permissionscontents: read, packages: read
Matrixservice × release (from generate-matrix.matrix)

Steps:

#StepAction / CommandDetails
1Checkoutactions/checkout@v6Checks out the repository (needed for test scripts, source-refs.yaml, and patch counting)
2Setup Docker registry.github/actions/setup-docker-registryGHCR login (cosign disabled); replaces inline login step
3Derive tags.github/actions/derive-service-tagsComposite action. Reconstructs tags using the same logic as build-service-images and merge-service-images
4Pull and verifyShelldocker pull <image-ref> then runs verify_${{ matrix.service }}.sh with the pulled image ref

Test script executed:

ScriptValidates
tests/container-images/verify_<service>.shService-specific checks (e.g. keystone-manage --version exits 0), runs as openstack user, no build tools (gcc, python3-dev, uv), runtime apt packages installed

The job fails the workflow if the verify script exits non-zero. Tag derivation uses the .github/actions/derive-service-tags composite action, which is the single source of truth shared by build-service-images, merge-service-images, and verify-service-images.

Tag Schema

Each service image build produces two to four tags on push events:

TagFormatExampleBranches
Composite<version>-p<N>-<branch>-<sha>keystone:28.0.0-p0-main-a1b2c3dall
Version<version>keystone:28.0.0main only
Release<release>keystone:2025.2main only
SHA<sha>keystone:a1b2c3dall

The version-only and release tags are restricted to the main branch to prevent silent overwrites when multiple branches build the same upstream version. The composite tag already encodes the branch, so stable/** builds remain uniquely identifiable.

Tag components:

ComponentSourceDescription
<version>releases/<release>/source-refs.yamlUpstream OpenStack version tag (e.g., 28.0.0)
p<N>Count of .patch files in patches/<service>/<release>/Patch count; defaults to p0 when the directory is absent
<branch>GITHUB_REF_NAME with / replaced by -Branch name, sanitized for Docker tag compatibility (e.g., stable/2025.2 becomes stable-2025.2)
<sha>First 7 characters of GITHUB_SHAShort commit SHA

The composite tag uniquely identifies the exact build: upstream version, patch level, branch, and commit. The version and SHA tags provide convenient shortcuts for deployment systems.

PR vs Push Behavior

The workflow behaves differently depending on the trigger event:

AspectPull RequestPush (main / stable/**)
Base imagesPer-platform digests pushed; multi-arch manifest assembled by merge-base-imagesSame
Base image verificationverify-base-images job (always runs)verify-base-images job (always runs)
Service image platformslinux/amd64 only (ARM64 excluded)linux/amd64,linux/arm64
Service image pushNo (load: true on amd64 runner)Yes (by digest, tags assigned by merge-service-images)
Service image tagsComputed but not publishedPublished to GHCR
SBOM generationSkippedCycloneDX JSON for every merged manifest
Vulnerability scanningImage-based scan on PR (image: input in build-service-images)SBOM-based scan in merge-service-images (sbom: input)
SBOM attestationSkippedSigstore-signed, pushed to GHCR
Cosign signingSkippedKeyless signature for every merged manifest
OIDC token requestNoneRequested in merge jobs for Sigstore signing
Service image verificationInline step in build-service-imagesSeparate verify-service-images job
Verification image sourceLocally loaded image (same amd64 runner)Pulled from GHCR

Why base images are always pushed: Service Dockerfiles reference base images via docker-image:// URIs in build contexts. This Docker BuildKit feature requires the referenced image to exist in a registry — local images are not sufficient. Pushing small base images on every PR is a deliberate trade-off to keep Dockerfiles registry-independent.

Why PRs use single-arch for service images: docker/build-push-action with load: true only supports single-platform builds. Multi-platform images cannot be loaded into the local Docker daemon. Since the inline verification step needs the image locally, PRs build only linux/amd64. Base images are still built for both platforms on PRs (by digest), so the native ARM runner is exercised without requiring a locally loaded result.

GHA Caching

All docker/build-push-action steps use GitHub Actions cache for Docker layers:

yaml
cache-from: type=gha,scope=<scope>
cache-to: type=gha,mode=max,scope=<scope>

Each image has a unique cache scope per platform to prevent cross-arch cache collisions:

ImageScope
python-basepython-base-linux-amd64 / python-base-linux-arm64
venv-buildervenv-builder-linux-amd64 / venv-builder-linux-arm64
Service images<service>-<release>-linux-amd64 / <service>-<release>-linux-arm64 (e.g., keystone-2025.2-linux-amd64)

The mode=max setting caches all intermediate layers, not just the final image layer.

Action Pinning

All actions are pinned to full commit SHAs with version comments, matching the convention in ci.yaml:

yaml
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd  # v4
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2  # v4
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051  # v5
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294  # v7
uses: anchore/sbom-action@17ae1740179002c89186b61233e0f892c3118b11  # v0
uses: anchore/scan-action@7037fa011853d5a11690026fb85feee79f4c946c  # v7
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26  # v4
uses: github/codeql-action/upload-sarif@820e3160e279568db735cee8ed8f8e77a6da7818  # v3
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad  # v4

This prevents supply-chain attacks via tag mutation while remaining auditable through version comments.

SBOM Generation and Attestation

Every container image pushed to GHCR on non-PR events receives a CycloneDX SBOM and a Sigstore-signed attestation. This enables consumers to audit image contents, check for known vulnerabilities, and verify that images have not been tampered with since build time.

Consolidation note. SBOM generation, attestation, vulnerability scanning, build provenance, and cosign signing are handled by a single parameterised composite action (.github/actions/supply-chain-attest). See supply-chain-attest for the composite action interface.

How It Works

After per-platform images are pushed by digest and the multi-arch manifest list is assembled by the merge job, the supply-chain-attest composite action runs in the merge job and performs:

  1. SBOM generation (anchore/sbom-action) — Syft scans the merged manifest (referenced by digest) and produces a CycloneDX JSON file covering both OS packages (dpkg) and Python packages (dist-info).

  2. SBOM attestation (actions/attest) — The CycloneDX file is signed via Sigstore keyless OIDC (using the GitHub Actions workflow identity) and pushed to GHCR as an OCI referrer artifact alongside the image. No signing keys are managed; the OIDC token binds the attestation to the specific workflow run.

This pattern is applied to all four image types via the supply-chain-attest composite action:

ImageSBOM output fileJob
python-basesbom-python-base.cyclonedx.jsonmerge-base-images
venv-buildersbom-venv-builder.cyclonedx.jsonmerge-base-images
tempestsbom-tempest-${{ matrix.release }}.cyclonedx.jsonmerge-tempest-image
Service (e.g., keystone)sbom-${{ matrix.service }}.cyclonedx.jsonmerge-service-images

PR Behavior

The supply-chain-attest composite action uses its scan-mode input to control PR vs push behavior. On pull requests, merge jobs pass scan-mode: image which runs only the Grype vulnerability scan (no SBOM generation, no attestation, no signing). On push events, merge jobs use the default scan-mode: sbom for the full pipeline.

On pull requests:

  • No SBOMs are generated or attested — scan-mode: image skips all SBOM/attestation steps.
  • No OIDC token requests occur — SBOM/attestation steps are skipped so id-token: write is not exercised.
  • No attestations are created for ephemeral PR builds.
  • PR CI time is not increased by SBOM/attestation steps.

Base images are always pushed to GHCR (even on PRs) because downstream service builds reference them via docker-image:// URIs. However, SBOM generation and attestation for base images are still skipped on PRs — the composite action's scan-mode guard applies uniformly.

Required Permissions

SBOM attestation requires two additional job-level permissions beyond the existing contents: read and packages: write:

PermissionPurpose
id-token: writeAllows the GitHub Actions runner to request a short-lived Sigstore OIDC token for keyless signing
attestations: writeGrants access to the GitHub Attestations API for storing signed attestations

These permissions are granted to merge-base-images, merge-tempest-image, merge-service-images, and build-service-images (for PR-only scans via supply-chain-attest with scan-mode: image). Verification jobs (verify-base-images, verify-service-images) do not receive these permissions.

Verifying Attestations

To verify that an image has a valid Sigstore-signed attestation:

bash
gh attestation verify oci://ghcr.io/<owner>/<service>:<tag> --owner <owner>

To extract the SBOM predicate from the attestation, first inspect the raw output to find the exact predicateType URI used in your environment:

bash
gh attestation verify oci://ghcr.io/<owner>/<service>:<tag> \
  --owner <owner> \
  --format json | jq '.[].verificationResult.statement.predicateType'

Then filter for that predicate type (the URI below may differ by action version):

bash
gh attestation verify oci://ghcr.io/<owner>/<service>:<tag> \
  --owner <owner> \
  --format json | jq -r '
    [ .[] | select(.verificationResult.statement.predicateType | test("cyclonedx")) ]
    | if length == 0 then error("no CycloneDX attestation found") else .[0].verificationResult.statement.predicate end
  '

Test Coverage

The verify_build_images_workflow.sh script validates SBOM/attestation configuration:

TestValidates
test_sbom_permissions_on_build_base_imagesid-token: write and attestations: write on build-base-images
test_sbom_permissions_on_build_service_imagesid-token: write and attestations: write on build-service-images
test_verify_jobs_no_sbom_permissionsVerification jobs do not have id-token or attestations permissions
test_sbom_generation_steps_existSBOM generation steps exist in both build jobs
test_sbom_format_cyclonedx_jsonAll SBOM steps specify format: cyclonedx-json
test_sbom_generation_references_digestSBOM steps reference the correct digest output
test_sbom_attestation_steps_existAttestation steps exist in both build jobs
test_sbom_attestation_push_to_registryAll attestation steps have push-to-registry: true
test_sbom_steps_pr_skip_guardAll SBOM/attestation steps have github.event_name != 'pull_request' guard

Cosign Image Signing

Every container image pushed to GHCR on non-PR events is signed with cosign keyless signing. This provides an independent signature layer alongside the SBOM attestation, enabling consumers to verify image provenance using standard Sigstore tooling.

How It Works

Cosign is installed by the setup-docker-registry composite action when install-cosign: 'true' (the default, used by merge jobs). The supply-chain-attest composite action then runs cosign sign --yes as the final step of the supply chain pipeline:

  1. cosign-installer (sigstore/cosign-installer@v4) — Installed by setup-docker-registry in merge jobs.

  2. cosign sign — Executed by supply-chain-attest in sbom mode. Signs the image by digest using Sigstore keyless OIDC. The --yes flag confirms non-interactive mode. No signing keys are managed; the GitHub Actions OIDC token binds the signature to the specific workflow run.

This pattern is applied to all four image types via the supply-chain-attest composite action:

ImageJobDigest source
python-basemerge-base-imagessteps.merge-python-base.outputs.digest
venv-buildermerge-base-imagessteps.merge-venv-builder.outputs.digest
tempestmerge-tempest-imagesteps.merge-tempest.outputs.digest
Service (e.g., keystone)merge-service-imagessteps.merge-service.outputs.digest

PR Behavior

The supply-chain-attest composite action skips cosign signing in image mode. On pull requests:

  • No images are signed — scan-mode: image skips all signing steps.
  • No OIDC token requests occur for signing.
  • The id-token: write permission (shared with SBOM attestation) is not exercised.

Required Permissions

Cosign keyless signing reuses the same id-token: write permission required by SBOM attestation. No additional permissions are needed.

Verifying Signatures

To verify that an image has a valid cosign signature:

bash
cosign verify \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  --certificate-identity-regexp "https://github.com/<owner>/<repo>/.github/workflows/build-images.yaml@refs/.*" \
  ghcr.io/<owner>/<service>@sha256:<digest>

Test Coverage

The verify_build_images_workflow.sh script validates cosign signing configuration:

TestValidates
test_cosign_installer_in_build_base_imagessigstore/cosign-installer step exists in build-base-images
test_cosign_installer_in_build_service_imagessigstore/cosign-installer step exists in build-service-images
test_cosign_sign_steps_count2 sign steps in build-base-images, 1 in build-service-images
test_cosign_sign_steps_pr_guardAll sign steps have github.event_name != 'pull_request' guard
test_cosign_sign_steps_reference_digestSign steps reference the correct digest output
test_cosign_sign_uses_yes_flagAll sign steps use the --yes flag
test_cosign_id_token_permission_commentid-token: write comment references cosign signing

Vulnerability Scanning

Every container image is scanned for known CVEs using Grype (via anchore/scan-action) on every push and pull request. Unlike SBOM generation, attestation, and cosign signing (which are skipped on PRs), vulnerability scanning runs on both event types to provide immediate feedback on high-severity CVEs before merging.

Consolidation note. Vulnerability scanning is part of the supply-chain-attest composite action. In sbom mode (push), Grype scans the SBOM. In image mode (PR), Grype scans the image directly. Both modes upload SARIF to the GitHub Security tab.

How It Works

The supply-chain-attest composite action includes two vulnerability scanning steps:

  1. Grype scan (anchore/scan-action) — Scans the image for known CVEs. On push events, Grype consumes the CycloneDX SBOM file (faster, offline-capable). On PR events, Grype scans the image directly (since SBOM generation is skipped on PRs). The scan uses severity-cutoff: high with fail-build: false. All CVEs at or above high severity are reported in SARIF but do not currently fail the build. Build failure on high/critical CVEs will be activated later.

  2. SARIF upload (github/codeql-action/upload-sarif) — Uploads Grype results to the GitHub Security tab in SARIF format. Each upload uses a unique category value to distinguish findings per image. The upload step runs with if: always() and a guard that checks for non-empty SARIF output, ensuring the upload is skipped cleanly if the scan step crashes without producing output.

This pattern is applied to all four image types via the supply-chain-attest composite action:

ImageSARIF categoryPush jobPR job
python-basegrype-python-basemerge-base-imagesmerge-base-images
venv-buildergrype-venv-buildermerge-base-imagesmerge-base-images
tempestgrype-tempest-${{ matrix.release }}merge-tempest-imagebuild-tempest
Service (e.g., keystone)grype-${{ matrix.service }}merge-service-imagesbuild-service-images

Scan Input: PR vs Push

The supply-chain-attest composite action uses its scan-mode input to select the scan strategy. Internally, the action has two mutually exclusive Grype scan steps — one for sbom mode and one for image mode — because anchore/scan-action documents sbom and image as mutually exclusive inputs:

scan-modeGrype inputSource
sbom (push)sbom:CycloneDX SBOM file generated in a prior step
image (PR)image:Image reference passed via image-ref-for-scan input

The SARIF upload step uses a fallback expression to reference whichever scan step produced output, since exactly one runs per invocation.

For base images on PRs, the image is referenced by digest from GHCR (base images are always pushed). For service images on PRs, the image is referenced by the composite tag from steps.tags.outputs.composite (service images use load: true on PRs, making them available locally).

Severity Threshold

All Grype scan steps use severity-cutoff: high with fail-build: false:

  • Critical and High severity CVEs are reported in SARIF but do not currently fail the build (build failure will be activated later)
  • Medium and Low severity CVEs are reported in SARIF but do not block the build
  • All findings are visible in the GitHub Security tab regardless of severity

CVE Suppression

A .grype.yaml configuration file at the repository root allows suppression of known-accepted CVEs. Grype automatically reads this file from the working directory (default behavior — no explicit configuration needed).

yaml
# .grype.yaml — add entries to suppress false positives
ignore:
  - id: CVE-YYYY-NNNNN
    reason: "<justification>"
    fix-state: "not-fixed"

The ignore list is initially empty. To add a CVE suppression, append an entry with the CVE identifier, a justification explaining why the CVE is acceptable, and optionally a fix-state field (fixed, not-fixed, wont-fix, or unknown). This prevents false-positive build failures on unfixable base-image vulnerabilities without disabling scanning entirely.

SARIF Integration

Grype scan results are uploaded to the GitHub Security tab via github/codeql-action/upload-sarif:

  • Each image has a unique SARIF category value (e.g., grype-python-base, grype-venv-builder, grype-<service>) for per-image categorization in the Security dashboard
  • Upload steps use if: always() with a guard for non-empty SARIF output, ensuring clean skip when a scan step crashes without producing output
  • Results appear in the repository's Security > Code scanning alerts tab

Required Permissions

SARIF upload requires security-events: write permission on merge jobs (merge-base-images, merge-tempest-image, merge-service-images) and on build-service-images (for PR-only scans). Verification jobs (verify-base-images, verify-service-images) do not receive this permission (least privilege).

Test Coverage

The verify_build_images_workflow.sh script validates vulnerability scanning configuration:

TestValidates
test_grype_scan_steps_in_build_base_images4 anchore/scan-action steps exist in build-base-images (2 per image: SBOM + image)
test_grype_scan_step_in_build_service_images2 anchore/scan-action steps exist in build-service-images (SBOM + image)
test_grype_scan_action_sha_pinnedanchore/scan-action is SHA-pinned with # v7 version comment
test_grype_scan_steps_cover_both_contextsEach image has both a push-context (SBOM) and PR-context (image) scan step with appropriate if: guards
test_grype_sbom_input_wiringSBOM input references correct filenames (sbom-python-base.cyclonedx.json, etc.)
test_grype_image_input_wiringImage input references correct image refs for PR context
test_grype_severity_thresholdAll Grype steps use severity-cutoff: high
test_grype_fail_build_falseAll Grype steps use fail-build: false
test_grype_output_format_sarifAll Grype steps use output-format: sarif
test_sarif_upload_steps_exist2 upload-sarif steps in build-base-images, 1 in build-service-images
test_sarif_upload_categoriesSARIF upload categories match image names (grype-python-base, etc.)
test_sarif_upload_always_conditionAll SARIF upload steps have if: always() with SARIF output guard
test_sarif_upload_action_sha_pinnedgithub/codeql-action/upload-sarif is SHA-pinned with # v3 version comment
test_sarif_upload_references_grype_outputSARIF upload sarif_file references Grype step output
test_security_events_permission_build_base_imagesbuild-base-images has security-events: write
test_security_events_permission_build_service_imagesbuild-service-images has security-events: write
test_verify_jobs_no_security_events_permissionVerify jobs do not have security-events permission
test_security_events_permission_commentsecurity-events permission comment references SARIF upload

OCI Annotations

Every container image receives OCI Image Spec annotations via a two-layer approach: static LABEL instructions in Dockerfiles provide baseline metadata for local builds, while docker/metadata-action in CI generates dynamic labels that supplement the static ones at push time.

Static Dockerfile Labels

Each Dockerfile includes a LABEL instruction with four OCI annotations that are always present, regardless of whether the image is built locally or in CI:

LabelValue (example for keystone)
org.opencontainers.image.titlekeystone
org.opencontainers.image.descriptionOpenStack keystone service
org.opencontainers.image.licensesApache-2.0
org.opencontainers.image.vendorSAP SE

In python-base and venv-builder, the LABEL instruction is placed after the last RUN instruction. In keystone, the LABEL is placed in Stage 2 (runtime, FROM python-base) before the USER instruction — Stage 1 (build) labels are discarded by Docker's multi-stage build process.

CI Metadata Action

In CI, each image has a docker/metadata-action step that generates OCI-compliant labels. The action auto-generates dynamic labels from the GitHub context:

Auto-generated labelSource
org.opencontainers.image.createdBuild timestamp (ISO 8601)
org.opencontainers.image.revisionGITHUB_SHA (40-character Git SHA)
org.opencontainers.image.sourceGitHub repository URL
org.opencontainers.image.urlGitHub repository URL
org.opencontainers.image.versionGit-derived version (or raw override for keystone)

The labels input of each metadata-action step provides the four custom labels (title, description, licenses, vendor). These supplement the auto-generated labels.

Metadata-action steps:

Step IDJobImage input
meta-python-basebuild-base-imagesghcr.io/${{ steps.meta.outputs.owner }}/python-base
meta-venv-builderbuild-base-imagesghcr.io/${{ steps.meta.outputs.owner }}/venv-builder
meta-servicebuild-service-images${{ steps.tags.outputs.image }}

Each metadata-action step's outputs.labels is wired into the corresponding build-push-action step via the labels input. At push time, CI-generated labels (created, revision, source, url, version) override any matching static Dockerfile labels, while the static labels serve as fallback for local builds where no metadata-action runs.

Keystone Version Override

By default, docker/metadata-action derives org.opencontainers.image.version from the Git context (branch name or tag). For keystone, this would produce a Git-derived version rather than the upstream OpenStack release version (e.g., 28.0.0).

To ensure the OCI version annotation reflects the actual software version, the meta-service step uses a type=raw tag strategy:

yaml
tags: |
  type=raw,value=${{ steps.source-ref.outputs.ref }}

This overrides the version to match the value from source-refs.yaml (e.g., 28.0.0). The base images (python-base, venv-builder) do not specify a tags input and use the default Git-derived version strategy, which is appropriate for infrastructure images without an upstream software version.

Note: The tags input on docker/metadata-action controls the org.opencontainers.image.version label, not the image tags passed to docker/build-push-action. Image tags continue to be computed by the "Derive tags" step (see Tag Schema). The metadata-action's tag strategies only influence the OCI version annotation.

Test Coverage

The verify_build_images_workflow.sh script validates OCI annotation configuration:

TestValidates
test_metadata_action_steps_exist_in_build_base_imagesbuild-base-images has meta-python-base and meta-venv-builder steps using docker/metadata-action
test_metadata_action_step_exists_in_build_service_imagesbuild-service-images has meta-service step using docker/metadata-action
test_service_metadata_uses_raw_version_strategymeta-service step uses type=raw with steps.source-ref.outputs.ref
test_base_metadata_steps_have_no_tags_overridemeta-python-base and meta-venv-builder do not specify a tags input
test_python_base_build_push_has_labels_inputbuild-python-base step wires steps.meta-python-base.outputs.labels
test_venv_builder_build_push_has_labels_inputbuild-venv-builder step wires steps.meta-venv-builder.outputs.labels
test_service_build_push_has_labels_inputbuild-service step wires steps.meta-service.outputs.labels
test_metadata_action_labels_include_oci_titleAll 3 metadata-action steps include org.opencontainers.image.title
test_metadata_action_labels_include_oci_descriptionAll 3 metadata-action steps include org.opencontainers.image.description
test_metadata_action_labels_include_oci_licensesAll 3 metadata-action steps include org.opencontainers.image.licenses=Apache-2.0
test_metadata_action_labels_include_oci_vendorAll 3 metadata-action steps include org.opencontainers.image.vendor
test_dockerfile_static_labels_python_baseimages/python-base/Dockerfile has LABEL for title, description, licenses, vendor
test_dockerfile_static_labels_venv_builderimages/venv-builder/Dockerfile has LABEL for title, description, licenses, vendor
test_dockerfile_static_labels_keystoneimages/keystone/Dockerfile has LABEL for title, description, licenses, vendor in Stage 2

Adding a New Service

To add a new service (e.g., nova) to the build matrix:

1. Create the Dockerfile

Add a Dockerfile at images/<service>/Dockerfile following the two-stage pattern in images/keystone/Dockerfile. The Dockerfile must use named build contexts (python-base, venv-builder, <service>, upper-constraints) — not hardcoded paths. Include a LABEL instruction in the runtime stage with OCI annotations (title, description, licenses, vendor) — see OCI Annotations.

2. Add the source ref

Add the service to releases/<release>/source-refs.yaml:

yaml
keystone: "28.0.0"
nova: "31.0.0"        # ← new entry

3. Add extra-packages entry

Add the service to releases/<release>/extra-packages.yaml with its Python extras, additional pip packages, and runtime system packages. This file is the source of truth for build arguments PIP_EXTRAS, PIP_PACKAGES, and EXTRA_APT_PACKAGES — the service will not build without an entry here.

yaml
nova:
  pip_extras:
    - oslo_vmware
  pip_packages: []
  apt_packages:
    - libvirt0

4. Verify matrix discovery

The generate-matrix job automatically discovers all services from source-refs.yaml in each releases/*/ directory. Adding the service to source-refs.yaml (step 2) is sufficient — no manual workflow matrix changes are needed. The job produces service × release matrices consumed by build-service-images, test-service-images, merge-service-images, and verify-service-images.

5. (Optional) Add patches

If the service requires patches, create patch files at patches/<service>/<release>/*.patch. The workflow applies them automatically when present; no workflow changes are needed.

6. (Optional) Add constraint overrides

If the service requires constraint overrides, add entries to overrides/<release>/constraints.txt. The apply-constraint-overrides.sh script processes them automatically.

7. (Optional) Add test exclusions

If the service has upstream unit tests that cannot pass in CI (environment-dependent, flaky, or infrastructure-requiring tests), create an exclusion file at releases/<release>/test-excludes/<service>.txt. The file uses stestr exclude-list format: one regex pattern per line, # for comments, blank lines allowed. The test-service-images job picks up the file automatically when present — no workflow changes are needed.

The tag derivation, build context resolution, source checkout (via checkout-service-source), unit test execution (via hack/ci-run-unit-tests.sh), supply chain attestation (via supply-chain-attest), and verification steps all use matrix variables and work automatically for new services. The verify-service-images job derives its own image refs independently via its own matrix strategy. Note that adding a new service also requires creating a corresponding verify_<service>.sh test script in tests/container-images/ and updating the inline PR verification step in build-service-images accordingly.

Adding a New Release

To add a new release series (e.g., 2026.1):

1. Create release configuration

Create the release directory with required files:

text
releases/2026.1/
├── extra-packages.yaml       # Extra pip/apt packages per service
├── source-refs.yaml          # Service versions for this release
├── test-refs.yaml            # PyPI version pins for test tooling
├── test-excludes/            # (Optional) Per-service stestr exclude-lists
│   └── <service>.txt
└── upper-constraints.txt     # From openstack/requirements stable/2026.1

extra-packages.yaml is required — the workflow reads it to resolve PIP_EXTRAS, PIP_PACKAGES, and EXTRA_APT_PACKAGES build arguments. test-refs.yaml is required — the build-tempest job reads it to resolve TEMPEST_VERSION and KEYSTONE_TEMPEST_PLUGIN_VERSION build arguments. See Container Images — extra-packages.yaml for the YAML schema and releases/2025.2/extra-packages.yaml for a working example.

2. Verify matrix discovery

The generate-matrix job automatically discovers all releases from releases/*/ directories. Creating the release directory in step 1 is sufficient — no manual workflow changes are needed. The job produces service × release matrices for all downstream jobs (build-service-images, merge-service-images, test-service-images, verify-service-images) and release-only matrices for Tempest pipeline jobs (build-tempest, merge-tempest-image). Verify discovery by checking the generate-matrix job output in a CI run.

3. (Optional) Add patches and overrides

Create patches/<service>/<release>/ and overrides/<release>/constraints.txt as needed.

4. (Optional) Add test exclusions

If any services have upstream tests that fail in the new release's CI environment, create releases/<release>/test-excludes/<service>.txt with stestr exclude-list patterns. Copy patterns from the previous release's exclusion file as a starting point and adjust as needed.

5. (Optional) Add Tempest test configuration

If multi-release Tempest testing is needed, create a release-specific Tempest configuration directory under tests/tempest/ (e.g., tests/tempest/keystone-<release>/) containing tempest.conf, include-tests.txt, exclude-tests.txt, and 00-keystone-cr.yaml. Then add a corresponding matrix entry to the tempest job in ci.yaml (see CI Workflow — tempest).

Verify Container Images Workflow

A separate workflow (.github/workflows/verify-container-images.yaml) runs static verification tests against container infrastructure files without requiring Docker. This workflow validates Dockerfiles, workflow structure, SPDX compliance, release configuration, and constraint override scripts.

Trigger Events

The workflow triggers on the same events as build-images.yaml:

EventScopeDescription
pushbranches: [main, stable/**]Runs on every push to main or any stable/** branch
pull_requestall branchesRuns on every pull request

Permissions and Concurrency

Top-level permissions are contents: read (least privilege). The concurrency group follows the standard pattern: ${{ github.ref }}-${{ github.workflow }} with cancel-in-progress limited to pull request events.

Job: verify-static-tests

A single job that runs all static test scripts sequentially. If any script exits non-zero, the job fails.

PropertyValue
runs-onubuntu-latest
timeout-minutes10

Steps:

#StepAction / CommandDetails
1Checkoutactions/checkout@v6Checks out the repository
2Install yqShellDownloads yq binary from GitHub releases (version-pinned via YQ_VERSION env var)
3Verify build-images workflow structureShellRuns tests/container-images/verify_build_images_workflow.sh
4Verify deviation commentsShellRuns tests/container-images/verify_deviation_comments.sh
5Verify release configShellRuns tests/container-images/verify_release_config.sh
6Verify SPDX headersShellRuns tests/container-images/verify_spdx_headers.sh
7Test apply-constraint-overridesShellRuns tests/scripts/test_apply_constraint_overrides.sh

Test scripts executed:

ScriptValidates
verify_build_images_workflow.shWorkflow structure: job names, dependency chain, trigger events, permissions, action pinning, concurrency, matrix strategy, SBOM/attestation configuration, cosign signing configuration, OCI annotation configuration, vulnerability scanning configuration, test-service-images job structure and steps
verify_deviation_comments.shDEVIATION comments in Dockerfiles cross-reference architecture docs
verify_release_config.shsource-refs.yaml, extra-packages.yaml structure and content validity, test-excludes file format and directory structure
verify_spdx_headers.shSPDX Apache-2.0 license headers present on all infrastructure files
test_apply_constraint_overrides.shscripts/apply-constraint-overrides.sh correctly applies constraint overrides

yq is installed before the test scripts because verify_build_images_workflow.sh and verify_release_config.sh use yq to parse YAML files. The installation uses a direct binary download from the mikefarah/yq GitHub releases (more reliable across runner updates than snap).

Relationship to build-images.yaml

The verify-container-images workflow is intentionally separate from build-images.yaml because it tests container infrastructure (file structure, conventions, configs) rather than the container images themselves. Separate workflows provide clear, independent signals in GitHub's check status UI. The Docker-based image verification tests (verify_python_base.sh, verify_venv_builder.sh, verify_<service>.sh) run inside build-images.yaml where the images are actually built.

Verification Coverage Summary

The following table summarizes which test scripts run where:

Test Scriptverify-container-images.yamlbuild-images.yamlRequires Docker
verify_build_images_workflow.shverify-static-testsNo
verify_deviation_comments.shverify-static-testsNo
verify_release_config.shverify-static-testsNo
verify_spdx_headers.shverify-static-testsNo
test_apply_constraint_overrides.shverify-static-testsNo
verify_python_base.shverify-base-imagesYes
verify_venv_builder.shverify-base-imagesYes
verify_<service>.shbuild-service-images (PR) / verify-service-images (push)Yes

SPDX Header

The file starts with the standard SPDX license header:

text
# SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company
#
# SPDX-License-Identifier: Apache-2.0
---

Dependencies on Base Images and Release Configs

The build-images workflow depends on the following artifacts:

ArtifactUsed byPurpose
images/python-base/Dockerfilebuild-base-imagesPython runtime base image
images/venv-builder/Dockerfilebuild-base-imagesBuild-stage image with uv and compilers
images/keystone/Dockerfilebuild-service-imagesKeystone service image (two-stage build)
releases/2025.2/source-refs.yamlbuild-service-imagesUpstream version resolution
releases/2025.2/upper-constraints.txtbuild-service-imagesPython dependency pins
scripts/apply-constraint-overrides.shbuild-service-imagesConstraint override application

:::