From 22360ea22b6947ba3cd500ec2dad3b27034b6c26 Mon Sep 17 00:00:00 2001 From: Larry Chen Date: Tue, 20 Jan 2026 19:01:18 +0800 Subject: [PATCH 1/2] chore: set up security container scan --- .../security-container-scan/action.yml | 204 ++++++++++++++++++ .../security-container-scan/grype_summary.py | 99 +++++++++ 2 files changed, 303 insertions(+) create mode 100644 .github/actions/security-container-scan/action.yml create mode 100644 .github/actions/security-container-scan/grype_summary.py diff --git a/.github/actions/security-container-scan/action.yml b/.github/actions/security-container-scan/action.yml new file mode 100644 index 0000000..ce00d4a --- /dev/null +++ b/.github/actions/security-container-scan/action.yml @@ -0,0 +1,204 @@ +name: security-container-scan +description: Generate SBOM (Syft) and scan a locally-built container image with Grype, uploading JSON/SARIF reports and writing a summary. + +inputs: + image: + description: 'Local container image reference to scan (must exist on the runner), e.g. "localbuild/testimage:latest"' + required: true + fail-on: + description: 'Minimum severity to fail (passed to grype as --fail-on). One of: negligible, low, medium, high, critical' + required: false + default: high + fail-build: + description: 'If true, fail this step when Grype finds vulnerabilities at/above fail-on (or when SBOM/scan prerequisites fail)' + required: false + default: 'false' + grype-image: + description: 'Grype container image to run' + required: false + default: anchore/grype:latest + report-json: + description: 'Filename for JSON report' + required: false + default: grype-results.json + report-sarif: + description: 'Filename for SARIF report' + required: false + default: grype-results.sarif + upload-artifact: + description: 'Upload reports as a workflow artifact' + required: false + default: 'true' + artifact-name: + description: 'Artifact name for uploaded reports' + required: false + default: grype-container-scan + generate-sbom: + description: 'Generate and upload SBOM using anchore/sbom-action' + required: false + default: 'true' + sbom-format: + description: 'SBOM format for sbom-action' + required: false + default: spdx-json + sbom-artifact-name: + description: 'Artifact name for the SBOM uploaded by sbom-action' + required: false + default: container.spdx.json + write-summary: + description: 'Write a human-friendly summary into $GITHUB_STEP_SUMMARY' + required: false + default: 'true' + +outputs: + status: + description: 'ok | high_or_error | image_not_found | pull_failed | sbom_failed | grype_unknown' + value: ${{ steps.final.outputs.status }} + detail: + description: 'Free-form detail string for status' + value: ${{ steps.final.outputs.detail }} + report_json: + description: 'Path to JSON report (if generated)' + value: ${{ steps.final.outputs.report_json }} + report_sarif: + description: 'Path to SARIF report (if generated)' + value: ${{ steps.final.outputs.report_sarif }} + +runs: + using: composite + steps: + - name: Precheck local image exists + id: precheck + shell: bash + run: | + set +e + IMAGE="${{ inputs.image }}" + docker image inspect "${IMAGE}" >/dev/null 2>&1 + rc=$? + if [ $rc -eq 0 ]; then + echo "image_exists=true" >> "$GITHUB_OUTPUT" + else + echo "image_exists=false" >> "$GITHUB_OUTPUT" + fi + exit 0 + + - name: Generate SBOM (Syft via sbom-action) + id: sbom + if: ${{ inputs.generate-sbom == 'true' && steps.precheck.outputs.image_exists == 'true' }} + continue-on-error: true + uses: anchore/sbom-action@v0 + with: + image: ${{ inputs.image }} + format: ${{ inputs.sbom-format }} + artifact-name: ${{ inputs.sbom-artifact-name }} + + - name: Run Grype scan (JSON + SARIF) + id: grype + if: ${{ steps.precheck.outputs.image_exists == 'true' }} + shell: bash + run: | + set +e + IMAGE="${{ inputs.image }}" + REPORT_JSON="${{ inputs.report-json }}" + REPORT_SARIF="${{ inputs.report-sarif }}" + FAIL_ON="${{ inputs.fail-on }}" + GRYPE_IMAGE="${{ inputs.grype-image }}" + + echo "Pulling Grype scanner image..." + docker pull "${GRYPE_IMAGE}" >/dev/null 2>&1 + pull_rc=$? + if [ $pull_rc -ne 0 ]; then + echo "status=pull_failed" >> "$GITHUB_OUTPUT" + echo "exit_code=3" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Scanning image: ${IMAGE}" + docker run --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v "$HOME/.cache/grype:/root/.cache/grype" \ + "${GRYPE_IMAGE}" \ + "${IMAGE}" --fail-on "${FAIL_ON}" -o json > "${REPORT_JSON}" + scan_rc=$? + + docker run --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v "$HOME/.cache/grype:/root/.cache/grype" \ + "${GRYPE_IMAGE}" \ + "${IMAGE}" -o sarif > "${REPORT_SARIF}" || true + + echo "exit_code=${scan_rc}" >> "$GITHUB_OUTPUT" + if [ $scan_rc -eq 0 ]; then + echo "status=ok" >> "$GITHUB_OUTPUT" + else + echo "status=high_or_error" >> "$GITHUB_OUTPUT" + fi + exit 0 + + - name: Upload Grype reports + if: ${{ inputs.upload-artifact == 'true' && steps.precheck.outputs.image_exists == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact-name }} + path: | + ${{ inputs.report-json }} + ${{ inputs.report-sarif }} + + - name: Write scan summary + if: ${{ inputs.write-summary == 'true' }} + shell: bash + run: | + set -euo pipefail + echo "### 🔍 Container Scan (SBOM + Grype)" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "- Image: \`${{ inputs.image }}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- Reports artifact: \`${{ inputs.artifact-name }}\` (sarif + json)" >> "$GITHUB_STEP_SUMMARY" + + if [ -f "${{ inputs.report-json }}" ]; then + python3 "$GITHUB_ACTION_PATH/grype_summary.py" --json "${{ inputs.report-json }}" --max-top 10 >> "$GITHUB_STEP_SUMMARY" || true + fi + + - name: Finalize status/outputs + id: final + if: always() + shell: bash + run: | + set -euo pipefail + + IMAGE_EXISTS="${{ steps.precheck.outputs.image_exists }}" + SBOM_OUTCOME="${{ steps.sbom.outcome }}" + GRYPE_STATUS="${{ steps.grype.outputs.status }}" + GRYPE_EXIT="${{ steps.grype.outputs.exit_code }}" + + status="ok" + detail="ok" + + if [ "${IMAGE_EXISTS}" != "true" ]; then + status="image_not_found" + detail="local docker image not found" + elif [ "${{ inputs.generate-sbom }}" = "true" ] && [ "${SBOM_OUTCOME}" != "success" ]; then + status="sbom_failed" + detail="sbom-action failed" + elif [ -z "${GRYPE_STATUS}" ]; then + status="grype_unknown" + detail="grype step did not produce status" + elif [ "${GRYPE_STATUS}" = "ok" ]; then + status="ok" + detail="no vulnerabilities at/above fail-on=${{ inputs.fail-on }}" + elif [ "${GRYPE_STATUS}" = "high_or_error" ]; then + status="high_or_error" + detail="grype exit_code=${GRYPE_EXIT} (fail-on=${{ inputs.fail-on }})" + else + status="${GRYPE_STATUS}" + detail="grype exit_code=${GRYPE_EXIT}" + fi + + echo "status=${status}" >> "$GITHUB_OUTPUT" + echo "detail=${detail}" >> "$GITHUB_OUTPUT" + echo "report_json=${{ inputs.report-json }}" >> "$GITHUB_OUTPUT" + echo "report_sarif=${{ inputs.report-sarif }}" >> "$GITHUB_OUTPUT" + + if [ "${{ inputs.fail-build }}" = "true" ] && [ "${status}" != "ok" ]; then + echo "Failing build due to status=${status}: ${detail}" 1>&2 + exit 1 + fi diff --git a/.github/actions/security-container-scan/grype_summary.py b/.github/actions/security-container-scan/grype_summary.py new file mode 100644 index 0000000..7cd74db --- /dev/null +++ b/.github/actions/security-container-scan/grype_summary.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +import argparse +import json +from collections import Counter +from typing import Any, Dict, List + + +def _safe_load_json(path: str) -> Dict[str, Any]: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def _matches(data: Dict[str, Any]) -> List[Dict[str, Any]]: + m = data.get("matches", []) + return m if isinstance(m, list) else [] + + +def main() -> int: + p = argparse.ArgumentParser( + description="Render a Grype JSON report into Markdown summary." + ) + p.add_argument( + "--json", + required=True, + help="Path to grype JSON report (from `grype -o json`).", + ) + p.add_argument( + "--max-top", + type=int, + default=10, + help="Max number of Critical/High rows to print.", + ) + args = p.parse_args() + + try: + data = _safe_load_json(args.json) + except Exception as e: + print("") + print("#### Grype Summary") + print(f"Unable to parse `{args.json}`: {e}") + return 0 + + matches = _matches(data) + sev_list: List[str] = [] + rows: List[Dict[str, str]] = [] + + for m in matches: + vuln = m.get("vulnerability") or {} + artifact = m.get("artifact") or {} + if not isinstance(vuln, dict) or not isinstance(artifact, dict): + continue + + sev = vuln.get("severity") or "Unknown" + if not isinstance(sev, str): + sev = "Unknown" + sev_list.append(sev) + + rows.append( + { + "severity": sev, + "id": str(vuln.get("id") or ""), + "pkg": str(artifact.get("name") or ""), + "ver": str(artifact.get("version") or ""), + } + ) + + cnt = Counter(sev_list) + + print("") + print("#### Grype Summary") + print(f"- Total matches: **{len(matches)}**") + print( + ( + "- Critical: **{c}**, High: **{h}**, Medium: **{m}**, " + "Low: **{l}**" + ).format( + c=cnt.get("Critical", 0), + h=cnt.get("High", 0), + m=cnt.get("Medium", 0), + l=cnt.get("Low", 0), + ) + ) + + top = [r for r in rows if r["severity"] in ("Critical", "High")] + top = top[: max(args.max_top, 0)] + if top: + print("") + print(f"#### Top Critical/High (first {len(top)})") + print("") + print("| Severity | Vulnerability | Package | Version |") + print("|---|---|---|---|") + for r in top: + print(f"| {r['severity']} | {r['id']} | {r['pkg']} | {r['ver']} |") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From d75c1890387c9690b84f9447f1321c3007d96d85 Mon Sep 17 00:00:00 2001 From: Larry Chen Date: Wed, 21 Jan 2026 13:12:20 +0800 Subject: [PATCH 2/2] chore: integrate security container scan to docker build --- .github/actions/docker-build/README.md | 95 +++++++++ .github/actions/docker-build/action.yml | 182 ++++++++++++++++++ .../docker-build/scripts/normalize-tags.sh | 80 ++++++++ .github/workflows/README.md | 35 ++++ .github/workflows/docker-build.yml | 174 +++++++++++++++++ README.md | 5 + 6 files changed, 571 insertions(+) create mode 100644 .github/actions/docker-build/README.md create mode 100644 .github/actions/docker-build/action.yml create mode 100644 .github/actions/docker-build/scripts/normalize-tags.sh create mode 100644 .github/workflows/docker-build.yml diff --git a/.github/actions/docker-build/README.md b/.github/actions/docker-build/README.md new file mode 100644 index 0000000..e64cb85 --- /dev/null +++ b/.github/actions/docker-build/README.md @@ -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-`. | 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. diff --git a/.github/actions/docker-build/action.yml b/.github/actions/docker-build/action.yml new file mode 100644 index 0000000..08a5480 --- /dev/null +++ b/.github/actions/docker-build/action.yml @@ -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-." + 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 }} diff --git a/.github/actions/docker-build/scripts/normalize-tags.sh b/.github/actions/docker-build/scripts/normalize-tags.sh new file mode 100644 index 0000000..0eca6db --- /dev/null +++ b/.github/actions/docker-build/scripts/normalize-tags.sh @@ -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<> "${GITHUB_OUTPUT}" diff --git a/.github/workflows/README.md b/.github/workflows/README.md index dff301a..3cb7db6 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -31,6 +31,41 @@ jobs: DEST_PASSWORD: ${{ secrets.GHCR_TOKEN }} ``` +## Docker Build (`docker-build.yml`) + +A reusable workflow wrapper for building (and optionally pushing) OCI images via Docker Buildx. It supports multi-arch builds and GitHub Actions cache (`type=gha`) through the underlying composite action. + +### Usage + +```yaml +jobs: + build: + uses: NVIDIA/dsx-github-actions/.github/workflows/docker-build.yml@main + with: + runner: ubuntu-latest + image: nvcr.io/myorg/myapp + tags: | + latest + sha-${{ github.sha }} + push: true + registry: nvcr.io + security_scan_enabled: true + security_scan_fail_on_critical: true + secrets: + REGISTRY_USERNAME: ${{ secrets.NVCR_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.NVCR_TOKEN }} +``` + +### Outputs + +- `digest`: Image digest +- `tags`: Normalized fully qualified tags used for build/push + +### Security scan options + +- `security_scan_enabled`: If true, performs a pre-build security scan on a locally-built `linux/amd64` image before the main build/push. +- `security_scan_fail_on_critical`: If true, fails the workflow when Critical vulnerabilities are found. Scan tool failures always fail and prevent pushing. + ## Release Workflow (`release.yml`) Automatically creates semantic version tags and releases when commits are pushed to the `main` branch using [semantic-release](https://github.com/semantic-release/semantic-release). diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..908a5c1 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,174 @@ +# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: Docker Build + +on: + workflow_call: + inputs: + runner: + description: "Runner label" + required: false + default: "ubuntu-latest" + type: string + image: + description: "Image repository without tag (e.g. nvcr.io/org/app or ghcr.io/org/app)" + required: true + type: string + tags: + description: "Tags to apply (comma or newline separated). Each item may be a tag or a full ref." + required: false + default: "" + type: string + context: + description: "Build context" + required: false + default: "." + type: string + dockerfile: + description: "Path to Dockerfile" + required: false + default: "Dockerfile" + type: string + platforms: + description: "Target platforms" + required: false + default: "linux/amd64,linux/arm64" + type: string + push: + description: "Whether to push image to registry" + required: false + default: false + type: boolean + registry: + description: "Registry host for docker/login-action (e.g. nvcr.io, ghcr.io). Leave empty for Docker Hub." + required: false + default: "" + type: string + cache: + description: "Enable GitHub Actions cache (type=gha)" + required: false + default: true + type: boolean + cache_scope: + description: "Cache scope for GHA cache. Defaults to a sanitized form of image." + required: false + default: "" + type: string + build_args: + description: "Build args (one per line, KEY=VALUE)" + required: false + default: "" + type: string + labels: + description: "OCI labels (one per line, key=value)" + required: false + default: "" + type: string + target: + description: "Build target stage" + required: false + default: "" + type: string + provenance: + description: "Generate provenance (empty uses docker/build-push-action default)" + required: false + default: "" + type: string + sbom: + description: "Generate SBOM (empty uses docker/build-push-action default)" + required: false + default: "" + type: string + security_scan_enabled: + description: "Run security-container-scan (SBOM + Grype) against a locally-built linux/amd64 image before the main build/push" + required: false + default: false + type: boolean + security_scan_fail_on_critical: + description: "If true, fail the workflow when Critical vulnerabilities are found during the security scan" + required: false + default: true + type: boolean + secrets: + REGISTRY_USERNAME: + description: "Registry username (optional; required only for push+login)" + required: false + REGISTRY_PASSWORD: + description: "Registry password/token (optional; required only for push+login)" + required: false + outputs: + digest: + description: "Image digest from build-push-action" + value: ${{ jobs.build.outputs.digest }} + tags: + description: "Normalized fully qualified image refs used for build/push" + value: ${{ jobs.build.outputs.tags }} + +jobs: + build: + runs-on: ${{ inputs.runner }} + permissions: + contents: read + packages: write + outputs: + digest: ${{ steps.docker.outputs.digest }} + tags: ${{ steps.docker.outputs.tags }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve dsx-github-actions ref + id: resolve + shell: bash + run: | + set -euo pipefail + # github.workflow_ref example: + # NVIDIA/dsx-github-actions/.github/workflows/docker-build.yml@v1.2.3 + wf_ref="${GITHUB_WORKFLOW_REF:-}" + ref="${wf_ref##*@}" + if [[ -z "${ref}" || "${ref}" == "${wf_ref}" ]]; then + ref="main" + fi + echo "ref=${ref}" >> "$GITHUB_OUTPUT" + + - name: Checkout dsx-github-actions (for local action) + uses: actions/checkout@v4 + with: + repository: NVIDIA/dsx-github-actions + ref: ${{ steps.resolve.outputs.ref }} + path: dsx-github-actions + + - name: Docker build + id: docker + uses: ./dsx-github-actions/.github/actions/docker-build + with: + image: ${{ inputs.image }} + tags: ${{ inputs.tags }} + context: ${{ inputs.context }} + dockerfile: ${{ inputs.dockerfile }} + platforms: ${{ inputs.platforms }} + push: ${{ inputs.push && 'true' || 'false' }} + registry: ${{ inputs.registry }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + cache: ${{ inputs.cache && 'true' || 'false' }} + cache-scope: ${{ inputs.cache_scope }} + build-args: ${{ inputs.build_args }} + labels: ${{ inputs.labels }} + target: ${{ inputs.target }} + provenance: ${{ inputs.provenance }} + sbom: ${{ inputs.sbom }} + security-scan-enabled: ${{ inputs.security_scan_enabled && 'true' || 'false' }} + security-scan-fail-on-critical: ${{ inputs.security_scan_fail_on_critical && 'true' || 'false' }} diff --git a/README.md b/README.md index d28e3f4..68c1cce 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A collection of reusable GitHub Actions for standardizing CI/CD workflows across | [trufflehog-scan](.github/actions/trufflehog-scan/) | Secret scanning with TruffleHog | Leaked credentials detection | | [semantic-release](.github/actions/semantic-release/) | Automated versioning and releases | Semantic versioning and changelog | | [resource-push-ngc](.github/actions/resource-push-ngc/) | Push resources to NGC | Artifact publishing | +| [docker-build](.github/actions/docker-build/) | Docker Buildx build/push wrapper | Build/push multi-arch OCI images | | [git-tag](.github/actions/git-tag/) | Create and push git tag | Tagging releases | | [slack-notify](.github/actions/slack-notify/) | Send notifications to Slack | CI/CD status notifications | @@ -19,6 +20,7 @@ A collection of reusable GitHub Actions for standardizing CI/CD workflows across | Workflow | Description | Use Case | | ------------------------------------------------------------------------ | ----------------------------------------------------- | --------------------------------------- | | [promote-image](.github/workflows/promote-image.yml) | Re-tag and re-publish multi-arch images via `skopeo` | Promote OCI images across registries | +| [docker-build](.github/workflows/docker-build.yml) | Reusable workflow wrapper for Docker build/push | Share Docker build logic across repos | ## ⚠️ Important: GitHub Advanced Security Required @@ -123,6 +125,7 @@ This reusable workflow wraps `skopeo copy`, so it copies the entire manifest lis - [TruffleHog Secret Scan Action](.github/actions/trufflehog-scan/README.md) - [Semantic Release Action](.github/actions/semantic-release/README.md) - [Resource Push NGC Action](.github/actions/resource-push-ngc/README.md) +- [Docker Build Action](.github/actions/docker-build/README.md) - [Slack Notify Action](.github/actions/slack-notify/README.md) - [Workflows Guide](.github/workflows/README.md) @@ -344,6 +347,7 @@ If CI still fails, execute `pre-commit run actionlint --all-files` or `pre-commi │ ├── codeql-scan/ # Static code analysis (CodeQL) │ ├── trivy-scan/ # Vulnerability scanning (Trivy) │ ├── trufflehog-scan/ # Secret scanning (TruffleHog) +│ ├── docker-build/ # Docker build/push wrapper │ ├── semantic-release/ # Automated versioning and releases │ ├── resource-push-ngc/ # NGC resources publishing │ ├── git-tag/ # Create and push git tag @@ -351,6 +355,7 @@ If CI still fails, execute `pre-commit run actionlint --all-files` or `pre-commi └── workflows/ ├── release.yml # Automatic semantic versioning ├── promote-image.yml # Promote image across registries + ├── docker-build.yml # Reusable Docker build/push wrapper └── README.md # Workflows documentation CONTRIBUTING.md # Contribution guidelines