Skip to content

Repository files navigation

🐳 Docker Reusable Workflows

Linux FoundationSource CodeLicense

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

WorkflowTrigger contextStatusPurpose
.github/workflows/build-test.yamlPull request / verifyImplementedImage discovery, buildx build, hadolint, test hook, SBOM, Grype scan
.github/workflows/build-test-release.yamlTag push (Model A)ImplementedTag-validated multi-platform build/push to GHCR/Docker Hub, cosign + SLSA
.github/workflows/merge.yamlMerge (Model B)ImplementedSnapshot/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:

RegistryOCI referrers APIcosign signature (tag scheme)Provenance destination
GHCRYesYesPushed to the registry
Docker HubUnreliableYesGitHub attestation store
Nexus 3No (404)Yes (verified on 3.95.1)GitHub attestation store
JFrog ArtifactoryUnverifiedUnverifiedGitHub 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 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 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.

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

InputTypeDefaultDescription
repositorystring''Repository to check out (owner/name); empty uses the caller
refstring''Branch/tag/SHA to check out (empty = default branch)
path_prefixstring'.'Path to the project root directory
imagesstring''JSON image list (see Image Discovery); empty string auto-discovers
image_namespacestring''Namespace prefixed to image names (e.g. onap -> onap/<name>)
build_commandstring''Escape hatch: project tooling builds the images (make/mvn/gradle)
build_timeout_minutesnumber30Timeout for the build job in whole minutes
build_permit_failbooleanfalsePermit image build failures; images that build carry on downstream
test_commandstring''Smoke-test hook; built images load first, IMAGES env carries tags
test_permit_failbooleanfalsePermit test failures without failing the workflow
lint_enabledbooleantrueRun the Dockerfile lint job; false skips it
lint_permit_failbooleanfalsePermit hadolint findings (the NO_BLOCK pattern)
audit_permit_failbooleanfalseDEPRECATED alias for lint_permit_fail; removed at the next major
sbom_enabledbooleantrueGenerate image SBOMs; false skips generation and the Grype scan
grype_enabledbooleantrueRun the Grype scan; false keeps the SBOMs but skips the scan
grype_fail_onstring'medium'Severity threshold that fails the Grype scan
grype_permit_failbooleanfalsePermit Grype findings without failing the job
harden_runner_egressstring'block'Harden-runner egress policy: block or audit
harden_runner_allowliststring(pinned)Out-of-band harden-runner allow-list configuration
build_permit_egress_trafficbooleanfalseAudit egress scoped to the build job (un-enumerable base registries)
gerrit_refspecstring''Gerrit refspec of the change under test
gerrit_projectstring''Gerrit project name
gerrit_branchstring''Gerrit target branch
gerrit_urlstring''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.

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):

InputTypeDefaultDescription
platformsstring'linux/amd64'Target platforms (csv); more than one engages QEMU + manifest lists
ghcr_publishbooleantruePublish to GHCR as ghcr.io/<owner>/<name> (GITHUB_TOKEN)
dockerhub_publishbooleanfalsePublish to Docker Hub as docker.io/<image_namespace>/<name>
image_namespacestring''Docker Hub namespace (required when dockerhub_publish is true)
push_latestbooleanfalseApply the latest tag per image at promotion, after all gates pass
attestationsbooleantrueSLSA build provenance per pushed image (by digest)
sigstore_signbooleantrueSigstore cosign keyless signature per pushed image (by digest)

One further input governs how hard signing failures land:

InputTypeDefaultDescription
sigstore_sign_required_registriesstring'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_timeout_minutes, hardening and gerrit_* inputs):

InputTypeDefaultDescription
snapshot_registrystring(none)Required. Snapshot/staging registry, host[:port][/path], e.g. nexus3.onap.org:10003
release_registrystring(none)Required. Release registry, host[:port][/path], e.g. nexus3.onap.org:10002
registry_userstring''Registry username override; empty derives from the repository name
nexus_userstring''Deprecated alias for registry_user
push_latestbooleanfalseTag promoted release images as latest too
dry_runbooleanfalseExercise 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: readpull-requests: read# yamllint disable-line rule:line-lengthuses: 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.

The publish lanes are not self-tested on pull requests: build-test-release.yaml requires a signed tag-push context (and pushes registry images), and merge.yaml requires a merged-commit context plus a version.properties file the fixtures lack. Instantiating repositories exercise those lanes through their own release/merge cycles.

About

Workflows for building and publishing Docker containers

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors