Skip to content

Repository files navigation

🐳 Docker Reusable Workflows

Linux Foundation Source Code License

Reusable GitHub workflows that build, lint, test, scan, publish and release Docker container images for the Linux Foundation. The workflows support both GitHub-native projects and projects where Gerrit serves as the source of truth (dispatched through gerrit_to_platform), and handle single Dockerfile repositories through to multi-image monorepos with same-repository FROM chains.

The design research behind this repository, including the ONAP container-build census the workflows target, lives in docs/BRIEF.md.

Workflow Inventory

Workflow Trigger context Status Purpose
.github/workflows/build-test.yaml Pull request / verify Implemented Image discovery, buildx build, hadolint, test hook, SBOM, Grype scan
.github/workflows/build-test-release.yaml Tag push (Model A) Implemented Tag-validated multi-platform build/push to GHCR/Docker Hub, cosign + SLSA
.github/workflows/merge.yaml Merge (Model B) Implemented Snapshot/staging publish (version.properties) + crane release promotion

Thin caller examples live under examples/, with a GitHub-native and a Gerrit-wrapped variant per workflow.

Release Models

Two release models cover the LF project estate:

  • Model A (tag-driven)build-test-release.yaml. A validated, signed semver tag drives the version. Images build (multi-platform capable) and push to GHCR (ghcr.io/<owner>/<name>) and optionally Docker Hub (docker.io/<image_namespace>/<name>), each pushed image signs with Sigstore cosign (keyless, by digest) and gains an SLSA build provenance attestation, and the audits gate promotion of the draft GitHub release (with per-image SBOMs and a digest manifest attached).
  • Model B (merge-driven)merge.yaml. The Jenkins-heritage LF/Gerrit flow: every merge builds the images and pushes the snapshot/staging tag set to the snapshot registry, versioned from version.properties. Merging a releases/ file with distribution_type: container triggers a registry-side promotion: crane copies the staged name:version images to the release registry at container_release_tag, preserving multi-architecture manifests without rebuilding.

Model B publishes this tag set per image, sharing one timestamp per run (the Jenkins include-docker-push.sh/fabric8 idiom):

X.Y.Z-SNAPSHOT-latest    rolling snapshot
X.Y-STAGING-latest       rolling staging
X.Y.Z-<ts>Z              immutable, promotable tag (what container
                         release files reference)

Registry Capability

Signing and provenance are registry-aware because registries differ in what supply-chain artefacts they accept. Model A routes around the gaps rather than assuming every target behaves like GHCR:

Registry OCI referrers API cosign signature (tag scheme) Provenance destination
GHCR Yes Yes Pushed to the registry
Docker Hub Unreliable Yes GitHub attestation store
Nexus 3 No (404) Yes (verified on 3.95.1) GitHub attestation store
JFrog Artifactory Unverified Unverified GitHub attestation store

Consequences for callers:

  • Provenance pushes to the registry for GHCR images (push-to-registry) and nowhere else. Elsewhere the attestation lives in the GitHub attestation store, which gh attestation verify reads.
  • Signatures run against every pushed image. A failure against a registry named in sigstore_sign_required_registries fails the release; elsewhere the image publishes unsigned with a warning, so an unproven registry cannot take a release down. Add a registry to that input once testing proves it stores signatures.
  • cosign v3.0.6, which sigstore/cosign-installer v4.1.2 installs, stores the signature under the legacy tag scheme, as sha256-<hex>.sig beside the image. OCI 1.1 referrer storage needs --registry-referrers-mode oci-1-1, which this lane does not pass, so cleanup policies and verification tooling should expect the .sig tag.

Nexus 3 accepts a cosign signature push (the signature image manifest under the sha256-<hex>.sig tag) even with strictContentTypeValidation enabled. This lane keeps cosign's legacy tag scheme by design and never passes --registry-referrers-mode oci-1-1: that path writes a subject-bearing manifest through the referrers API instead, and neither Nexus 3 nor Docker Hub supports it dependably.

Artifactory is addressable by merge.yaml (Model B), which accepts its repository-path and subdomain forms alongside the Nexus port-per-repository form. Its supply-chain columns above stay Unverified: nothing has yet pushed a cosign signature to an Artifactory instance from this lane, and Model B does not sign at all (snapshot tags are transient). Model A publishes to GHCR and Docker Hub, so releasing to Artifactory awaits a generic registry target there — tracked in #26.

Job Graph

build-test.yaml (-> denotes sequence; { } runs in parallel):

gerrit-validate -> { repository-metadata | docker-metadata }
docker-metadata -> { dockerfile-lint | build }
build -> { tests | sbom -> grype }

The dockerfile-lint (hadolint) job gates on docker-metadata rather than build: Dockerfile lint needs no built image, so lint findings surface even when the build itself fails.

build-test-release.yaml:

gerrit-validate -> { repository-metadata | tag-validate
  | docker-metadata }
docker-metadata -> { dockerfile-lint | build }
tag-validate -> build -> sign
build -> sbom -> grype
{ dockerfile-lint | grype } -> tests -> attach-artefacts
  -> promote-release

merge.yaml:

gerrit-validate -> { repository-metadata | docker-metadata
  | resolve-version | check-release }
docker-metadata -> build
{ resolve-version | build } -> snapshot-publish
check-release -> release-publish

Image Discovery

With no images input, the docker-metadata job walks path_prefix for Dockerfiles at the locations observed across the LF project estate:

  1. Dockerfile (repository root)
  2. docker/Dockerfile
  3. src/main/docker/Dockerfile (Maven convention)
  4. <dir>/Dockerfile (one-level per-image directories)

Image names derive from the repository or directory name. Pass the images JSON input to override discovery; its array order is the build order, which also serves same-repository FROM chains (an image can build FROM <earlier-image>:verify). Each entry takes name and context (required), plus optional dockerfile, target and build_args (a list of KEY=VALUE strings). Names in an explicit images input must stay distinct once normalised to the Docker repository character set; auto-discovery instead keeps the first of any duplicate pair.

With image_namespace set, the Dockerfile build path in every lane tags each built image both as <namespace>/<name>:verify and as <name>:verify, so a chain resolves whichever form it references and one images input travels between the verify, merge and release lanes unchanged. Two cases sit outside that guarantee: build_command hands back whatever tags the project's own tooling created, and a publishing release build whose platforms input is anything other than linux/amd64 — one foreign architecture as readily as a list of them — runs on the isolated docker-container driver, which cannot see daemon-local tags at all. Chains in either case must reference registry-resolvable images or take the base as a build_args value.

The qualifier matters for dry runs. A release build that publishes nowhere produces one platform rather than a manifest list, so it runs on the daemon-backed docker driver whatever platforms asks for, and daemon-local chains resolve as they do elsewhere. A dry run of a non-native project thus behaves like the verify lane rather than like a publishing multi-platform release.

The release lane skips the namespaced alias when image_namespace is not a usable reference prefix (-team or team., say), rather than failing a release over a value it never publishes under.

Where build_command builds the images, discovery finding no Dockerfile is not an error: jib and Gradle plugins synthesise images without one, and the build job enumerates whatever the command created.

That enumeration is inference: the workflow diffs the daemon's image list around the command and treats a registry digest as evidence that an image arrived from a registry instead of a local build. The evidence holds under Docker's classic image store, where a local build carries no digest. Under the containerd image store every image has one, so nothing distinguishes the two and the build stops with an error naming the images it could not attribute, rather than guessing and risking an unrelated base reaching the scan or publish jobs.

Declare the output with build_command_images in that case, and on such a store when the builder stamps a reproducible creation timestamp — jib pins it to the Unix epoch — since the workflow then has nothing left to tell a fresh build from a pulled base. On the classic store those images need no declaring, because the absent digest already identifies them.

Declaring skips the inference entirely. Each reference needs an explicit tag, a repeated reference is an error, and the workflow clears any existing tag of that name before running the command, so on a daemon reused between runs a command that builds nothing cannot pass its previous image off as fresh output.

The build job exports every built image as a docker archive, so the test, SBOM and scan jobs consume the exact bits built. Verify-lane and merge-lane builds run single-platform (the runner's native platform); the release lane builds multi-platform when the platforms input lists more than one target.

Inputs

build-test.yaml

Input Type Default Description
repository string '' Repository to check out (owner/name); empty uses the caller
ref string '' Branch/tag/SHA to check out (empty = default branch)
path_prefix string '.' Path to the project root directory
images string '' JSON image list (see Image Discovery); empty string auto-discovers
image_namespace string '' Namespace prefixed to image names (e.g. onap -> onap/<name>)
build_command string '' Escape hatch: project tooling builds the images (make/mvn/gradle)
build_command_images string '' Images build_command produces; declaring them skips inference
build_timeout_minutes number 30 Timeout for the build job in whole minutes
build_permit_fail boolean false Permit image build failures; images that build carry on downstream
test_command string '' Smoke-test hook; built images load first, IMAGES env carries tags
test_permit_fail boolean false Permit test failures without failing the workflow
lint_enabled boolean true Run the Dockerfile lint job; false skips it
lint_permit_fail boolean false Permit hadolint findings (the NO_BLOCK pattern)
audit_permit_fail boolean false DEPRECATED alias for lint_permit_fail; removed at the next major
sbom_enabled boolean true Generate image SBOMs; false skips generation and the Grype scan
grype_enabled boolean true Run the Grype scan; false keeps the SBOMs but skips the scan
grype_fail_on string 'medium' Severity threshold that fails the Grype scan
grype_permit_fail boolean false Permit Grype findings without failing the job
grype_cache_db string 'true' Grype database cache mode; parallel callers need a single writer
harden_runner_egress string 'block' Harden-runner egress policy: block or audit
harden_runner_allowlist string (pinned) Out-of-band harden-runner allow-list configuration
build_permit_egress_traffic boolean false Audit egress scoped to the build job (un-enumerable base registries)
gerrit_refspec string '' Gerrit refspec of the change under test
gerrit_project string '' Gerrit project name
gerrit_branch string '' Gerrit target branch
gerrit_url string '' Gerrit server URL; empty falls back to the GERRIT_URL variable

The workflow takes no secrets. Lint, test and scan failures honour the org-wide NO_BLOCK_AUDIT_FAIL repository variable as a runtime escape hatch alongside the per-call *_permit_fail inputs.

grype_cache_db decides how the Grype scan uses the Actions cache for its vulnerability database. The cache keys rotate on the database build time, so one job at most may write a given prefix. A caller invoking a lane once needs nothing here. A caller invoking it in parallel, a matrix leg per component, should leave one call saving and switch the rest to restoring without saving; where two jobs save, creation order and build order can disagree, and a restore then returns an older database than the newest saved. Grype rejects a database built more than five days ago, so a cache that drifts stale fails the scan rather than slowing it.

The input's own description names the three modes, and examples/build-test/ shows the parallel arrangement.

build-test-release.yaml

Adds to the shared inputs (repository, ref, path_prefix, images, build_timeout_minutes, hardening and gerrit_* inputs, test_command/test_permit_fail, lint_enabled/lint_permit_fail (and its deprecated audit_permit_fail alias), sbom_enabled, grype_enabled/grype_fail_on/grype_permit_fail/grype_cache_db):

Input Type Default Description
platforms string 'linux/amd64' Target platforms (csv); more than one engages QEMU + manifest lists
ghcr_publish boolean true Publish to GHCR as ghcr.io/<owner>/<name> (GITHUB_TOKEN)
dockerhub_publish boolean false Publish to Docker Hub as docker.io/<image_namespace>/<name>
image_namespace string '' Docker Hub namespace (required when dockerhub_publish is true)
push_latest boolean false Apply the latest tag per image at promotion, after all gates pass
dry_run boolean false Run the lane without publishing: no push, release, promotion or tag
attestations boolean true SLSA build provenance per pushed image (by digest)
sigstore_sign boolean true Sigstore cosign keyless signature per pushed image (by digest)

One further input governs how hard signing failures land:

Input Type Default Description
sigstore_sign_required_registries string 'ghcr.io docker.io' Registries whose signing failures fail the release (* = all, '' = none)

See Registry Capability for what the default list reflects and when to extend it.

Optional secrets: DOCKERHUB_USERNAME/DOCKERHUB_PASSWORD (the Docker Hub leg skips with a warning when unset). Callers grant contents: write, id-token: write, attestations: write and packages: write. The build_command escape hatch is absent from this lane by design: project-tooling builds cannot produce multi-platform manifests or per-registry digests reliably, so repositories needing it release through merge.yaml.

merge.yaml

Adds to the shared inputs (repository, ref, path_prefix, images, image_namespace, build_command, build_command_images, build_timeout_minutes, hardening and gerrit_* inputs):

Input Type Default Description
snapshot_registry string (none) Required. Snapshot/staging registry, host[:port][/path], e.g. nexus3.onap.org:10003
release_registry string (none) Required. Release registry, host[:port][/path], e.g. nexus3.onap.org:10002
registry_user string '' Registry username override; empty derives from the repository name
nexus_user string '' Deprecated alias for registry_user
push_latest boolean false Tag promoted release images as latest too
dry_run boolean false Exercise the publish/promotion lanes without credentials or pushes

The registry inputs take a host, an optional port and an optional repository path, which covers how the platforms in use address a repository:

nexus3.onap.org:10003              Nexus 3, repository on a port
acme.jfrog.io/docker-snapshot      Artifactory, repository-path method
docker-snapshot.acme.jfrog.io      Artifactory, subdomain method

The path belongs to the image reference, not the login: the workflow authenticates to host[:port] and pushes to the full base, so one credential covers every repository on an instance. Path components take Docker's repository grammar — lowercase, runs of alphanumerics joined by a single . or _, a doubled __, or one or more - — because anything else fails when Docker or crane parses the reference, and the input check exists to catch that before a publish begins.

Egress allow-list

Addressing a registry is half the job: under the default harden_runner_egress: block, the host must also appear in the allow-list harden_runner_allowlist pins, and that list enumerates hosts and ports rather than wildcards. Today it carries four JFrog tenants at :443aswf, hyperledger, odpi and zowe — alongside the Nexus 3 hosts at ports 10001-10004.

So a project on one of those tenants using the repository-path form (aswf.jfrog.io/docker-local) needs no extra setup, while these do:

  • any other tenant, since there is no *.jfrog.io entry
  • the subdomain method, where each repository is a distinct host (docker-snapshot.acme.jfrog.io) and so a distinct entry
  • any port other than 443

In those cases pass a harden_runner_allowlist that permits the endpoint, or the publish fails at the network layer before the registry ever answers. Preferring the repository-path form keeps the allow-list to one entry per instance rather than one per repository.

A release file's container_pull_registry/container_push_registry overrides may change the port or the repository path, but not the host, because the promotion job authenticates there with the loaded credential and an arbitrary host in a merged file could exfiltrate it. Under Artifactory's subdomain method each repository is a host, so overrides cannot move between repositories; use the repository-path form where a release file needs that freedom.

Optional secrets: OP_SERVICE_ACCOUNT_TOKEN/VAULT_MAPPING_JSON (the 1Password credential model shared across the workflow families; the publish jobs skip with a warning when unset). The version comes from version.properties at the project root, and release promotion triggers on merged releases/ files with distribution_type: container (the LF self-release container schema, including its optional container_pull_registry/ container_push_registry overrides).

Usage

GitHub-native caller

jobs:
  build-test:
    permissions:
      contents: read
      pull-requests: read
    # yamllint disable-line rule:line-length
    uses: lfreleng-actions/docker-workflows/.github/workflows/build-test.yaml@<SHA>  # vX.Y.Z

Pin the uses: reference to the commit SHA of a docker-workflows release. Never use a mutable ref such as @main, which follows whatever lands upstream without review. See examples/build-test/ for complete callers.

Gerrit-wrapped caller

For projects where Gerrit serves as the source of truth, gerrit_to_platform dispatches caller workflows through workflow_dispatch with nine GERRIT_* inputs. The naming contract requires the verify caller filename to contain both gerrit and verify (for example gerrit-verify.yaml). See the gerrit.yaml variant under examples/build-test/, including vote/comment plumbing.

Self-testing

.github/workflows/testing.yaml exercises build-test.yaml on pull requests against pinned fixture releases: test-docker-project (single image under docker/) and test-docker-monorepo (three images with a same-repository FROM chain), covering auto-discovery, explicit image lists with per-image build arguments, image namespacing, the build_command escape hatch and the test_command hook.

Both publish lanes are also self-tested, under dry_run. Neither publishes: they build, audit and test the fixture images, then report the tags, assets and promotion a real run would produce. merge.yaml runs against the fixture commit that adds a release descriptor, and build-test-release.yaml against its signed v0.1.0 tag, so tag validation applies at full strength.

A dry run cannot reach the behaviour that appears after images push: cosign signing, SLSA provenance, per-registry digest capture, the crane promotion itself, and multi-platform manifest assembly, which needs a registry to hold the list. Instantiating repositories still prove those through their own release and merge cycles.

One consequence worth knowing before calling build-test-release.yaml with dry_run, self-test or otherwise: GitHub checks a called workflow's job permissions against the caller's grant before any job starts, and rejects the whole run when the callee asks for more. Because permissions: takes no expression, the lane's declarations cannot shrink for a dry run, so a caller must grant contents, packages, id-token and attestations write even though a dry run uses none of them. merge.yaml needs no more than contents: read, because it authenticates to registries with a loaded credential rather than GITHUB_TOKEN.

About

Workflows for building and publishing Docker containers

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors