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 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.
| 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_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 |
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.
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):
| 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 |
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_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: readpull-requests: read# yamllint disable-line rule:line-lengthuses: 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.
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.