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 | 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.
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 fromversion.properties. Merging areleases/file withdistribution_type: containertriggers a registry-side promotion: crane copies the stagedname:versionimages to the release registry atcontainer_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)
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, whichgh attestation verifyreads. - Signatures run against every pushed image. A failure against a
registry named in
sigstore_sign_required_registriesfails 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-installerv4.1.2 installs, stores the signature under the legacy tag scheme, assha256-<hex>.sigbeside 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.sigtag.
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.
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
With no images input, the docker-metadata job walks path_prefix
for Dockerfiles at the locations observed across the LF project
estate:
Dockerfile(repository root)docker/Dockerfilesrc/main/docker/Dockerfile(Maven convention)<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.
| 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.
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.
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.
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 :443 — aswf, 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.ioentry - 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).
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.ZPin 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.
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.
.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.