Adding New Images

Step-by-step guide for adding new container images to the project

This guide covers two scenarios:

  • Adding a new image - Creating an entirely new image (e.g., adding nginx for the first time)
  • Adding a versioned image - Adding a new version of an existing image (e.g., go-1-25 when go already exists)

Choose the appropriate section below based on your use case.

Adding Versioned Language Images

When adding a new version of an existing language runtime or SDK (e.g., go-1-25, nodejs-24, dotnet-sdk-10-0), follow this streamlined workflow.

1. Check Package Availability

Versioned packages may be available in different repositories:

  • Hummingbird repository - Custom packages built by the Hummingbird project (e.g., golang1.25, nodejs24, dotnet-sdk-10.0)
  • Fedora repositories - Standard Fedora packages (e.g., golang, python3)

The project uses two distros:

  • rawhide - Uses only Fedora Rawhide packages (fedora-44.repo)
  • hummingbird - Uses Fedora 43 + Hummingbird packages (fedora-43.repo + hummingbird.repo)

To check if a package is available in Hummingbird:

# Check the Hummingbird RPMs repository
ls ../rpms/rpms/ | grep <package-name>

Important: If the package is only available in the Hummingbird repository, add distros: [hummingbird] to properties.yml (see step 3).

2. Create Image Directory and Copy Files

Follow the naming pattern <language>-<version> with dashes (e.g., go-1-25, nodejs-24, dotnet-sdk-10-0):

# Create directory
mkdir images/go-1-25

# Copy templates from the base image (not from generic templates)
cp images/go/Containerfile.j2 images/go-1-25/
cp images/go/tests-container.yml images/go-1-25/
cp images/go/report.yml images/go-1-25/

Do not copy README.md.j2 - the README is typically shared across versions and generated automatically.

3. Configure properties.yml

Create images/go-1-25/properties.yml with the versioned package:

---
main_package: golang1.25
repository: go
# endoflife.date: parallel at major.minor (Docker Hub + Wolfi)
stream: "1.25"
distros:
  - hummingbird  # Only if package is unavailable in Fedora
user: root
tags:
  # NOTE: Do NOT include 'latest' or major-version tags for older versions
  # Only the newest version (e.g., go-1-26) should have those tags
  - value: '{{ package_major_minor_version("golang1.25") }}'
    label: io.hummingbird-project.major-minor-version
  - value: '{{ package_version("golang1.25") }}'
    label: org.opencontainers.image.version
rpm_packages:
  all:
    - coreutils-single
    - git-core
    - glibc-devel
    - golang1.25
    - make

Key differences from a new image:

  • Set main_package to the versioned package name
  • Set repository to match the base image family (for registry organization)
  • Set stream to the version this image tracks (e.g., "1.25" for go-1-25), with a YAML comment above it documenting the reasoning (copy from an existing sibling image)
  • Add distros: [hummingbird] if the package is only in the Hummingbird repository
  • Tag strategy for multiple versions:
    • Newest version only (e.g., go-1-26): Include latest and major-version tags
    • Older versions (e.g., go-1-25): Include only major-minor-version and version tags
    • This prevents tag conflicts where multiple images would claim the same major version tag

4. Update Containerfile Template

Edit images/go-1-25/Containerfile.j2 to reference the versioned package in any environment variables or version-specific commands:

 ENV GOPATH=/go \
     GOTOOLCHAIN=local \
-    GOLANG_VERSION={{ package_version('golang') }} \
+    GOLANG_VERSION={{ package_version('golang1.25') }} \
     PATH=/go/bin:/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

5. Update Comparison Images

Edit images/go-1-25/report.yml to compare against version-specific upstream images:

compare:
  - pull: docker.io/golang:1.25
    url: https://hub.docker.com/_/golang

  - pull: cgr.dev/chainguard/go:latest-dev
    url: https://images.chainguard.dev/directory/image/go/overview

6. Tag Strategy for Multiple Versions

When multiple versions of the same language exist (e.g., both go-1-25 and go-1-26), coordinate tags to avoid conflicts:

Newest version (go-1-26):

tags:
  - value: latest                                        # Points to newest
  - value: '{{ package_major_version("golang1.26") }}'   # e.g., "1"
    label: io.hummingbird-project.major-version
  - value: '{{ package_major_minor_version("golang1.26") }}'  # e.g., "1.26"
    label: io.hummingbird-project.major-minor-version
  - value: '{{ package_version("golang1.26") }}'         # Full version
    label: org.opencontainers.image.version

Older versions (go-1-25):

tags:
  # NO 'latest' tag
  # NO major-version tag (would conflict with go-1-26's "1" tag)
  - value: '{{ package_major_minor_version("golang1.25") }}'  # e.g., "1.25"
    label: io.hummingbird-project.major-minor-version
  - value: '{{ package_version("golang1.25") }}'         # Full version
    label: org.opencontainers.image.version

Why: This ensures users can reference:

  • quay.io/hummingbird/go:latest → go-1-26
  • quay.io/hummingbird/go:1 → go-1-26 (latest 1.x)
  • quay.io/hummingbird/go:1.25 → go-1-25 (specific minor version)
  • quay.io/hummingbird/go:1.26 → go-1-26 (specific minor version)

When to update: If you add go-1-27, update go-1-26 to remove its latest and major-version tags, and add them to go-1-27.

7. Generate Files and Test

# Generate distro directories, lockfiles, VERSION, TAGS, etc.
make

# Build the image
ci/build_images.sh go-1-25/hummingbird/default

# Run tests
ci/run_tests_container.sh go-1-25/hummingbird/default

# Run linters
make check

Common Issues

Error: No match for argument: golang1.25

The package is not available in the configured distro repositories. Add distros: [hummingbird] to properties.yml and remove any generated rawhide/ directories:

rm -rf images/go-1-25/rawhide

Adding Completely New Images

Follow these steps to add an entirely new container image to the project.

1. Create Image Directory

Create a directory for the new image:

mkdir images/your-service

2. Copy Base Templates

Copy the template files:

cp images/properties.yml images/your-service/properties.yml
cp images/Containerfile.j2 images/your-service/Containerfile.j2

3. Configure Image Properties

Edit images/your-service/properties.yml to configure:

  • Variants: Define variants (default: [default, builder])
  • Packages: List RPM packages needed in rpm_packages.all
  • Main package: Set main_package for version labeling
  • Repository and stream: Set repository and stream (both required). Add a YAML comment above stream documenting the reasoning (see Choosing a Stream Value)
  • Tags: Configure image tags

Example minimal configuration:

---
rpm_packages:
  all:
    - coreutils-single
    - your-main-package

main_package: your-main-package
repository: your-service
# <reasoning for stream choice> -- see "Choosing a Stream Value" section
stream: "latest"

tags:
  - value: latest

See the Image Configuration Reference for complete properties.yml options.

4. Customize Containerfile Template

Edit images/your-service/Containerfile.j2:

  • Add service-specific configuration
  • Configure exposed ports, volumes, and entrypoint
  • Add {{ set_user() }} at the end of the template to set the container user

The template automatically includes packages from properties.yml via the {{ main_packages_arg() }} macro.

The {{ set_user() }} macro sets the USER directive based on the user: field in properties.yml (which defaults to default if not specified).

Multi-Architecture Support

When Containerfiles or build scripts need to reference the target architecture (e.g., downloading architecture-specific binaries), use the correct naming convention for each context.

Docker Linux
Intel amd64 x86_64
ARM arm64 aarch64
Containerfile $TARGETARCH ARG variable $(arch) output
  • In Containerfiles or Go builds: Use the $TARGETARCH ARG variable provided by buildah (values: amd64, arm64)
  • In shell scripts or RPM builds: Use the arch or uname -m commands (values: x86_64, aarch64)

5. Add Integration Tests

Create test files in images/your-service/:

  • tests-container.yml - Container tests (Podman/Docker)
  • tests-k8s.yml - K8s tests

Container test example:

---
version-check:
  command: |
    test_engine_run --rm "${TEST_IMAGE}" your-service --version

K8s test example:

---
run-as-pod:
  command: |
    name="test-pod-${TEST_RUN_ID}"
    kubectl run "${name}" --image="${TEST_IMAGE}" --restart=Never --labels="${TEST_RUN_LABEL}"
    kubectl wait --for=jsonpath='{.status.phase}'=Succeeded "pod/${name}" --timeout=60s

See the Testing Guide for how to write and run tests.

6. Add Image Documentation

Create images/your-service/README.md.j2 with a high-level description, usage instructions, and compatibility notes. The template generates multiple README files for different target audiences (defined in images/variables.yml under readme_targets).

Important notes:

  • The readme_heading() macro automatically generates the heading with the correct product name
  • The readme_description() macro renders the image description from description in properties.yml; use it after the heading instead of hardcoding introductory text
  • Use {{ readme_targets[target].registry }} for registry references (not hardcoded quay.io/hummingbird)
  • The readme_standard_sections() macro generates sections for tags, compatibility, verification, and vulnerabilities

Example template:

{{ readme_heading() }}

{{ readme_description() }}

## Usage

podman run -d -p 8080:8080 {{ readme_targets[target].registry }}/your-service:latest

{{ readme_standard_sections(variants, tags) }}

For more details on README templates, see the README Generation System section in .claude/CLAUDE.md.

7. Follow Standard Workflow

Follow the standard contribution workflow from the Development Workflow guide:

  • Generate files (make)
  • Build locally (ci/build_images.sh your-service)
  • Test locally (ci/run_tests_container.sh and ci/run_tests_k8s.sh)
  • Run linters (make check)
  • Commit and push changes
  • Open a merge request
  • Wait for CI pipeline

8. Deploy Konflux Resources and Trigger Testing

On the merge request pipeline, click the play button next to the deploy_resources_to_konflux job to deploy the Konflux resources for the new image. After the job completes, add a /retest comment to the merge request to trigger the CI pipeline for the newly added image.

Choosing a Stream Value

Every image must have a stream field in properties.yml. The stream is a version series identity (e.g., "24", "3.11", "10.1", "latest") that is emitted as the io.hummingbird-project.stream label on the container image. It enables the image catalog to provide consistent structured data about multi-stream repositories.

Why Stream Matters

The image catalog must provide consistent structured data across time, including for images published before a second stream exists. Labels on published images cannot be changed retroactively. If a repository gains a new stream, all previously published images must already carry the correct version-based stream value — otherwise the catalog has inconsistent data that requires fragile repository-specific fixups.

Decision Process

flowchart TD
    Start["New image: choose stream value"] --> Q1{"Does endoflife.date show\nmultiple concurrently\nsupported branches?"}
    Q1 -->|Yes| Assess["Assess ecosystem granularity:\nupstream project, Docker Hub,\nDebian/Ubuntu, Wolfi/Chainguard,\nFedora, endoflife.date"]
    Assess --> Version["Use version-based stream\n(match ecosystem granularity)"]
    Q1 -->|No| Q2{"Does the software have a\nmeaningful version series\nidentity?"}
    Q2 -->|Yes| Current["Use version-based stream\n(current major version)"]
    Q2 -->|No| Latest["stream: latest"]
    Version --> Doc["Document reasoning\nas YAML comment"]
    Current --> Doc
    Latest --> Doc

Document the reasoning for the stream choice as a YAML comment above the stream field in properties.yml. This ensures future maintainers understand why a particular granularity was chosen:

# endoflife.date: parallel at major.minor (2.8, 3.0, 3.2)
stream: "3.0"
# rolling release with date-based versions, no version branches
stream: "latest"

Step 1: Does endoflife.date show multiple concurrently supported branches?

Check endoflife.date for the upstream project. It provides structured data on upstream release branches, security support timelines, and versioning schemes for most projects. If it shows multiple concurrently supported branches, use a version-based stream at the granularity of those branches. If the product is not on endoflife.date, that is itself evidence that parallel version lifecycle management is not a concern for that software — proceed to step 2.

The stream granularity must match the level at which upstream maintains parallel security-supported branches. Check:

  • Upstream project: At what version granularity are parallel branches maintained?
  • Docker Hub official images: Are there parallel version tags? At what granularity?
  • Debian/Ubuntu: Are there parallel versioned packages?
  • Wolfi/Chainguard: Are there versioned package variants?
  • Fedora: Are there parallel versioned packages? Single-version availability is not evidence against versioning, but multi-version availability is strong evidence for it.

Important: the image’s directory name is not evidence for granularity. The directory may use a coarser version or no version at all. The stream must reflect upstream’s actual branch structure.

Similarly, the purpose of upstream’s branches (LTS vs stable, stable vs mainline, STS vs LTS) is irrelevant to granularity. If they are parallel branches receiving security updates, the stream granularity must match.

Match the upstream project’s version naming convention. If the project and ecosystem consistently identify branches using a format that includes a minor component (e.g., .NET uses 8.0, 9.0 — never just 8 or 9), use that format for the stream value. This applies even when endoflife.date abbreviates to just the major number.

Step 2: Does the software have a meaningful version series identity?

If the software uses numbered versioning where the major (or major.minor) number identifies a version series, use a version-based stream at the current major version. The risk is asymmetric: using a version-based stream when a new major version never ships is harmless (the label is still correct), while using "latest" when a new version does ship breaks catalog consistency.

Note: if endoflife.date lists minor versions (e.g., memcached 1.4, 1.5, 1.6) but each immediately supersedes the previous with no overlap in support periods, the minor number is a sequential release counter, not a branch identity. Use the major version as the stream.

Reserve "latest" only for images where no meaningful version series identity exists. This is a narrow criterion with two cases:

  • Rolling/date-based releases with no version series (e.g., minio uses RELEASE.2024-01-18...)
  • Base images that track the distro rather than independent software (e.g., core-runtime)

Step 3: For unversioned images, track the current version

For images without a version in their directory name or version constraints, the stream value reflects the version currently shipped and must be updated when the upstream version changes. This is analogous to version_constraints — an explicit declaration that is updated periodically.

Field Reference

See the Image Configuration Reference for the stream field definition and examples.

Next Steps