Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions .github/actions/docker-build/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Docker Build

Build (and optionally push) OCI images with Docker Buildx. Supports multi-arch builds and GitHub Actions cache (`type=gha`).

## Usage

### Build only (no push)

```yaml
steps:
- uses: actions/checkout@v4

- name: Build image (no push)
uses: NVIDIA/dsx-github-actions/.github/actions/docker-build@main
with:
image: ghcr.io/myorg/myapp
tags: |
sha-${{ github.sha }}
push: "false"
```

### Build + push (with registry login)

```yaml
steps:
- uses: actions/checkout@v4

- name: Build and push
uses: NVIDIA/dsx-github-actions/.github/actions/docker-build@main
with:
image: nvcr.io/myorg/myapp
tags: |
latest
sha-${{ github.sha }}
push: "true"
registry: nvcr.io
username: ${{ secrets.NVCR_USERNAME }}
password: ${{ secrets.NVCR_TOKEN }}
```

### Build + security scan gate + push

```yaml
steps:
- uses: actions/checkout@v4

- name: Build, scan, and push
uses: NVIDIA/dsx-github-actions/.github/actions/docker-build@main
with:
image: nvcr.io/myorg/myapp
tags: |
latest
sha-${{ github.sha }}
security-scan-enabled: "true"
security-scan-fail-on-critical: "true" # set to "false" to allow criticals without failing
push: "true"
registry: nvcr.io
username: ${{ secrets.NVCR_USERNAME }}
password: ${{ secrets.NVCR_TOKEN }}
```

## Inputs

| Input | Description | Required | Default |
| --- | --- | --- | --- |
| `image` | Image repository without tag (e.g. `nvcr.io/org/app` or `ghcr.io/org/app`) | Yes | |
| `tags` | Tags (comma or newline separated). Each item may be a tag (`latest`) or a full ref (`ghcr.io/org/app:latest`). If empty, defaults to `sha-<shortsha>`. | No | `""` |
| `context` | Build context | No | `.` |
| `dockerfile` | Path to Dockerfile | No | `Dockerfile` |
| `platforms` | Target platforms | No | `linux/amd64,linux/arm64` |
| `push` | Whether to push | No | `false` |
| `registry` | Registry host for login (`nvcr.io`, `ghcr.io`). Empty means Docker Hub. | No | `""` |
| `username` | Registry username (used when `push: "true"`) | No | `""` |
| `password` | Registry password/token (used when `push: "true"`) | No | `""` |
| `cache` | Enable GitHub Actions cache (`type=gha`) | No | `true` |
| `cache-scope` | Cache scope (defaults to sanitized `image`) | No | `""` |
| `build-args` | Build args (one per line, `KEY=VALUE`) | No | `""` |
| `labels` | OCI labels (one per line, `key=value`) | No | `""` |
| `target` | Target stage | No | `""` |
| `provenance` | Provenance setting (empty uses docker default) | No | `""` |
| `sbom` | SBOM setting (empty uses docker default) | No | `""` |
| `security-scan-enabled` | If `true`, run SBOM+Grype scan on a locally-built `linux/amd64` image before main build/push | No | `false` |
| `security-scan-fail-on-critical` | If `true`, fail when Critical vulnerabilities are found | No | `true` |

## Outputs

| Output | Description |
| --- | --- |
| `digest` | Image digest reported by `docker/build-push-action` |
| `tags` | Normalized fully qualified image refs used for the build |

## Notes

- If `push: "true"` but `username/password` are not provided, this action assumes you have already logged in earlier in the job.
- When `security-scan-enabled: "true"`, this action builds a temporary local `linux/amd64` image to scan, then proceeds to the main (possibly multi-arch) build/push if the scan policy allows.
182 changes: 182 additions & 0 deletions .github/actions/docker-build/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
name: "Docker Build"
description: "Build (and optionally push) OCI images with Buildx, multi-arch, and GHA cache"
author: "CDS Platform"

inputs:
image:
description: "Image repository without tag (e.g. nvcr.io/org/app or ghcr.io/org/app)"
required: true
tags:
description: "Tags to apply. Accepts comma or newline separated list. Each item may be a tag (e.g. latest) or a full ref (e.g. ghcr.io/org/app:latest). If omitted, defaults to sha-<shortsha>."
required: false
default: ""
context:
description: "Build context"
required: false
default: "."
dockerfile:
description: "Path to Dockerfile"
required: false
default: "Dockerfile"
platforms:
description: "Target platforms for multi-arch build"
required: false
default: "linux/amd64,linux/arm64"
push:
description: "Whether to push image to registry"
required: false
default: "false"
registry:
description: "Registry host for docker/login-action (e.g. nvcr.io, ghcr.io). Leave empty for Docker Hub."
required: false
default: ""
username:
description: "Registry username for docker/login-action"
required: false
default: ""
password:
description: "Registry password/token for docker/login-action"
required: false
default: ""
cache:
description: "Enable GitHub Actions cache (type=gha)"
required: false
default: "true"
cache-scope:
description: "Cache scope for GHA cache. Defaults to a sanitized form of image."
required: false
default: ""
build-args:
description: "Build args (one per line, KEY=VALUE)"
required: false
default: ""
labels:
description: "OCI labels (one per line, key=value)"
required: false
default: ""
target:
description: "Build target stage"
required: false
default: ""
provenance:
description: "Generate provenance (e.g. false, true, mode=max). Empty uses docker/build-push-action default."
required: false
default: ""
sbom:
description: "Generate SBOM (e.g. false, true). Empty uses docker/build-push-action default."
required: false
default: ""
security-scan-enabled:
description: "If true, build a local linux/amd64 image and run SBOM+Grype scan before the main build/push. Any scan tool failure will fail the action and prevent pushing."
required: false
default: "false"
security-scan-fail-on-critical:
description: "If true, fail the action when Critical vulnerabilities are found during security scan."
required: false
default: "true"

outputs:
digest:
description: "Image digest from build-push-action"
value: ${{ steps.build.outputs.digest }}
tags:
description: "Normalized fully qualified image refs used for build/push"
value: ${{ steps.normalize.outputs.tags }}

runs:
using: "composite"
steps:
- name: Prepare scripts
shell: bash
run: |
chmod +x "$GITHUB_ACTION_PATH"/scripts/*.sh

- name: Normalize tags and cache settings
id: normalize
shell: bash
env:
INPUT_IMAGE: ${{ inputs.image }}
INPUT_TAGS: ${{ inputs.tags }}
INPUT_CACHE_ENABLED: ${{ inputs.cache }}
INPUT_CACHE_SCOPE: ${{ inputs.cache-scope }}
run: |
"$GITHUB_ACTION_PATH"/scripts/normalize-tags.sh

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Prepare security scan vars
if: inputs.security-scan-enabled == 'true'
id: scan_vars
shell: bash
run: |
set -euo pipefail
scan_image="localbuild/dsx-docker-build-scan:${{ github.run_id }}-${{ github.run_attempt }}"
echo "scan_image=${scan_image}" >> "$GITHUB_OUTPUT"
echo "report_json=${RUNNER_TEMP}/grype-results-${{ github.run_id }}-${{ github.run_attempt }}.json" >> "$GITHUB_OUTPUT"
echo "report_sarif=${RUNNER_TEMP}/grype-results-${{ github.run_id }}-${{ github.run_attempt }}.sarif" >> "$GITHUB_OUTPUT"
echo "artifact_name=docker-build-scan-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT"

- name: Pre-scan build (local load, linux/amd64)
if: inputs.security-scan-enabled == 'true'
uses: docker/build-push-action@v5
with:
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
platforms: linux/amd64
push: false
load: true
tags: ${{ steps.scan_vars.outputs.scan_image }}
labels: ${{ inputs.labels }}
build-args: ${{ inputs.build-args }}
target: ${{ inputs.target }}
cache-from: ${{ steps.normalize.outputs.cache-from }}
cache-to: ${{ steps.normalize.outputs.cache-to }}

- name: Precheck local image exists (for scan)
if: inputs.security-scan-enabled == 'true'
shell: bash
run: |
set -euo pipefail
docker image inspect "${{ steps.scan_vars.outputs.scan_image }}" >/dev/null 2>&1

- name: Security scan (SBOM + Grype)
if: inputs.security-scan-enabled == 'true'
id: secscan
uses: NVIDIA/dsx-github-actions/.github/actions/security-container-scan@d8d304286ba834f32ff672d5c90782f2bbab4aea
with:
image: ${{ steps.scan_vars.outputs.scan_image }}
fail-on: critical
# gate ourselves to support "fail on critical" toggle while still failing on tool errors
fail-build: ${{ inputs.security-scan-fail-on-critical }}
report-json: ${{ steps.scan_vars.outputs.report_json }}
report-sarif: ${{ steps.scan_vars.outputs.report_sarif }}
artifact-name: ${{ steps.scan_vars.outputs.artifact_name }}

- name: Login to registry
if: inputs.push == 'true' && inputs.username != '' && inputs.password != ''
uses: docker/login-action@v3
with:
registry: ${{ inputs.registry }}
username: ${{ inputs.username }}
password: ${{ inputs.password }}

- name: Build and push
id: build
uses: docker/build-push-action@v5
with:
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
platforms: ${{ inputs.platforms }}
push: ${{ inputs.push }}
tags: ${{ steps.normalize.outputs.tags }}
labels: ${{ inputs.labels }}
build-args: ${{ inputs.build-args }}
target: ${{ inputs.target }}
provenance: ${{ inputs.provenance }}
sbom: ${{ inputs.sbom }}
cache-from: ${{ steps.normalize.outputs.cache-from }}
cache-to: ${{ steps.normalize.outputs.cache-to }}
80 changes: 80 additions & 0 deletions .github/actions/docker-build/scripts/normalize-tags.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail

image="${INPUT_IMAGE:-}"
raw_tags="${INPUT_TAGS:-}"
cache_enabled="${INPUT_CACHE_ENABLED:-true}"
cache_scope_input="${INPUT_CACHE_SCOPE:-}"

if [[ -z "${image}" ]]; then
echo "ERROR: input 'image' is required." >&2
exit 2
fi

short_sha=""
if [[ -n "${GITHUB_SHA:-}" ]]; then
short_sha="${GITHUB_SHA:0:7}"
fi

default_tag="sha-${short_sha:-unknown}"

normalize_list() {
# turns commas into newlines, trims whitespace, removes empty lines
echo "$1" \
| tr ',' '\n' \
| sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' \
| sed '/^$/d'
}

is_full_ref() {
# very lightweight detection for fully qualified image refs:
# - contains '@' (digest reference)
# - OR contains '/' and ':' (registry/repo + tag)
local t="$1"
if [[ "$t" == *@* ]]; then
return 0
fi
if [[ "$t" == */* && "$t" == *:* ]]; then
return 0
fi
return 1
}

tags_list="$(normalize_list "${raw_tags}")"
if [[ -z "${tags_list}" ]]; then
tags_list="${default_tag}"
fi

normalized_refs=""
while IFS= read -r t; do
if is_full_ref "${t}"; then
normalized_refs+="${t}"$'\n'
else
normalized_refs+="${image}:${t}"$'\n'
fi
done <<< "${tags_list}"

# trim trailing newline
normalized_refs="$(printf "%s" "${normalized_refs}" | sed '/^$/d')"

cache_scope="${cache_scope_input}"
if [[ -z "${cache_scope}" ]]; then
cache_scope="$(echo "${image}" | tr '/:@' '---' | tr -cs 'a-zA-Z0-9._-' '-')"
cache_scope="${cache_scope%-}"
fi

cache_from=""
cache_to=""
if [[ "${cache_enabled}" == "true" ]]; then
cache_from="type=gha,scope=${cache_scope}"
cache_to="type=gha,mode=max,scope=${cache_scope}"
fi

{
echo "tags<<EOF"
echo "${normalized_refs}"
echo "EOF"
echo "cache-scope=${cache_scope}"
echo "cache-from=${cache_from}"
echo "cache-to=${cache_to}"
} >> "${GITHUB_OUTPUT}"
Loading