From f07ad7306c7694b5d5f8be58e5edbe0a72231e3a Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Tue, 21 Jul 2026 08:07:54 +0200 Subject: [PATCH] feat(workflows): add docker-image CI gold-standard reusables (lint-compose, docker-image-ci) Signed-off-by: Sebastian Mendel --- .github/workflows/docker-image-ci.yml | 274 ++++++++++++++++++++++++++ .github/workflows/lint-compose.yml | 163 +++++++++++++++ docs/reusable-workflow-permissions.md | 3 +- 3 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/docker-image-ci.yml create mode 100644 .github/workflows/lint-compose.yml diff --git a/.github/workflows/docker-image-ci.yml b/.github/workflows/docker-image-ci.yml new file mode 100644 index 0000000..fc76218 --- /dev/null +++ b/.github/workflows/docker-image-ci.yml @@ -0,0 +1,274 @@ +# Reusable "Docker image CI" gold-standard workflow (META). +# +# Composes the granular container reusables into one CI entry point: +# Dockerfile lint (+ optional shellcheck), YAML / Markdown / compose lint, +# a validation-only image build with Trivy scanning, optional +# container-structure-test smoke test, CodeQL, secret scanning and +# dependency review. Each job calls a single-purpose reusable so consumers +# get one call site. +# +# This is the CI gate — it does NOT push, sign or attest. The companion +# publish workflow (build-container.yml, called directly on tag/release) +# owns multi-arch push + cosign + SLSA attestation. Here the build is +# `push: false` (validation-only), single-arch for speed. +# +# Caller pattern: +# +# name: CI +# on: +# push: +# branches: [main] +# pull_request: +# permissions: {} +# jobs: +# docker-ci: +# uses: netresearch/.github/.github/workflows/docker-image-ci.yml@main +# permissions: +# contents: read +# packages: write +# security-events: write +# id-token: write +# attestations: write +# actions: read +# pull-requests: write +# with: +# image-name: my-app +# lint-compose: true +# enable-smoke-test: true +# smoke-image-tag: my-app:ci +# secrets: +# GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} +# +# SECURITY: secrets are forwarded explicitly (GITLEAKS_LICENSE), never via +# `secrets: inherit`. Composed first-party reusables track `@main` by policy +# (see .github/zizmor.yml); third-party actions inside those leaves are +# SHA-pinned. + +name: Docker Image CI (reusable) + +on: + workflow_call: + inputs: + image-name: + description: "Image name without registry prefix (e.g. my-app). Passed to build-container for the validation build." + required: true + type: string + dockerfile: + description: "Path to the Dockerfile (build + smoke-test)." + type: string + default: "./Dockerfile" + context: + description: "Docker build context (build + smoke-test)." + type: string + default: "." + platforms: + description: "Build platforms for the validation build. Single-arch by default for CI speed; the publish workflow does multi-arch." + type: string + default: "linux/amd64" + + lint-container: + description: "Run the Dockerfile lint (hadolint) job." + type: boolean + default: true + shell-scandirs: + description: "Space-separated directories to scan with shellcheck (forwarded to lint-container). Empty skips shellcheck." + type: string + default: "" + lint-yaml: + description: "Run the YAML lint job." + type: boolean + default: true + lint-markdown: + description: "Run the Markdown lint job." + type: boolean + default: false + lint-compose: + description: "Run the docker compose config-validation job." + type: boolean + default: false + compose-files: + description: "Space-separated compose files to validate (forwarded to lint-compose)." + type: string + default: "compose.yml" + env-substitutions: + description: "Newline-separated KEY=VALUE placeholder pairs for compose validation (forwarded to lint-compose)." + type: string + default: "" + + enable-codeql: + description: "Run the CodeQL job." + type: boolean + default: false + codeql-languages: + description: "CodeQL language(s) to analyze (forwarded to codeql)." + type: string + default: "actions" + enable-gitleaks: + description: "Run the gitleaks secret-scanning job." + type: boolean + default: true + + enable-smoke-test: + description: "Run the container-structure-test smoke-test job." + type: boolean + default: false + smoke-image-tag: + description: "Local tag for the smoke-test build (forwarded to smoke-test-container). Empty defaults to `:ci`." + type: string + default: "" + smoke-cst-config-path: + description: "Path to the container-structure-test config (forwarded to smoke-test-container)." + type: string + default: "tests/container-structure-test.yaml" + smoke-target: + description: "Build stage / target for the smoke-test build. Empty builds the final stage." + type: string + default: "" + smoke-build-args: + description: "Newline-separated KEY=VALUE build args for the smoke-test build (forwarded to smoke-test-container)." + type: string + default: "" + + trivy-severity: + description: "Trivy severity filter for the build's vulnerability scan (forwarded to build-container)." + type: string + default: "CRITICAL,HIGH" + secrets: + GITLEAKS_LICENSE: + description: "Deprecated / accepted for backwards compatibility (betterleaks needs no license). Forwarded to the gitleaks job." + required: false + +# CALLER REQUIREMENTS +# =================== +# This meta-reusable composes leaves that need SARIF-upload, PR-write and +# container-build scopes, so its caller must grant the UNION of every +# composed job's requirements. When calling this reusable workflow, the +# caller's job-level `permissions:` block MUST grant at least this full set, +# otherwise the run fails with `startup_failure` before any job executes +# (GitHub rejects the workflow at startup when a nested reusable declares a +# permission the caller did not grant). NOTE: setting ANY scope in a +# `permissions:` block forces every UNLISTED scope to `none`, and a job that +# omits `permissions:` inherits the repository default +# (default_workflow_permissions) — so a repo hardened to `read` will +# startup-fail here unless these scopes are granted explicitly. Secrets are +# forwarded explicitly (GITLEAKS_LICENSE); never use `secrets: inherit`. +# +# IMPORTANT — packages/id-token/attestations are REQUIRED even though this +# CI meta sets `push: false`, `sign: false`, `attest: false`. The `build` +# job of build-container.yml STATICALLY declares +# `packages: write`, `id-token: write`, `attestations: write`; the caller +# chain must grant them regardless of the runtime inputs, or the run +# startup-fails. (See docs/reusable-workflow-permissions.md §1.) +# +# jobs: +# docker-ci: +# uses: netresearch/.github/.github/workflows/docker-image-ci.yml@main +# permissions: +# contents: read +# packages: write +# security-events: write +# id-token: write +# attestations: write +# actions: read +# pull-requests: write +permissions: {} + +jobs: + lint-container: + name: Lint Container + if: ${{ inputs.lint-container }} + uses: netresearch/.github/.github/workflows/lint-container.yml@main + permissions: + contents: read + with: + dockerfile-path: ${{ inputs.dockerfile }} + shell-scandirs: ${{ inputs.shell-scandirs }} + + lint-yaml: + name: Lint YAML + if: ${{ inputs.lint-yaml }} + uses: netresearch/.github/.github/workflows/lint-yaml.yml@main + permissions: + contents: read + + lint-markdown: + name: Lint Markdown + if: ${{ inputs.lint-markdown }} + uses: netresearch/.github/.github/workflows/lint-markdown.yml@main + permissions: + contents: read + + lint-compose: + name: Lint Compose + if: ${{ inputs.lint-compose }} + uses: netresearch/.github/.github/workflows/lint-compose.yml@main + permissions: + contents: read + with: + compose-files: ${{ inputs.compose-files }} + env-substitutions: ${{ inputs.env-substitutions }} + + build: + name: Build (validation) + uses: netresearch/.github/.github/workflows/build-container.yml@main + # build-container's `build` job statically declares the full set below, + # so the caller must grant it even though push/sign/attest are false. + permissions: + contents: read + packages: write + security-events: write + id-token: write + attestations: write + with: + image-name: ${{ inputs.image-name }} + dockerfile: ${{ inputs.dockerfile }} + context: ${{ inputs.context }} + platforms: ${{ inputs.platforms }} + push: false + scan: true + trivy-severity: ${{ inputs.trivy-severity }} + sign: false + attest: false + + smoke-test: + name: Smoke Test + if: ${{ inputs.enable-smoke-test }} + uses: netresearch/.github/.github/workflows/smoke-test-container.yml@main + permissions: + contents: read + with: + image-tag: ${{ inputs.smoke-image-tag != '' && inputs.smoke-image-tag || format('{0}:ci', inputs.image-name) }} + context: ${{ inputs.context }} + dockerfile-path: ${{ inputs.dockerfile }} + target: ${{ inputs.smoke-target }} + build-args: ${{ inputs.smoke-build-args }} + cst-config-path: ${{ inputs.smoke-cst-config-path }} + + codeql: + name: CodeQL + if: ${{ inputs.enable-codeql }} + uses: netresearch/.github/.github/workflows/codeql.yml@main + permissions: + actions: read + contents: read + security-events: write + with: + languages: ${{ inputs.codeql-languages }} + + gitleaks: + name: Secret Scanning + if: ${{ inputs.enable-gitleaks }} + uses: netresearch/.github/.github/workflows/gitleaks.yml@main + permissions: + contents: read + security-events: write + secrets: + GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} + + dependency-review: + name: Dependency Review + if: ${{ github.event_name == 'pull_request' }} + uses: netresearch/.github/.github/workflows/dependency-review.yml@main + permissions: + contents: read + pull-requests: write diff --git a/.github/workflows/lint-compose.yml b/.github/workflows/lint-compose.yml new file mode 100644 index 0000000..cb7c5db --- /dev/null +++ b/.github/workflows/lint-compose.yml @@ -0,0 +1,163 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Netresearch DTT GmbH +# +# Reusable "Lint Compose" — validates docker compose configuration by +# running `docker compose config --quiet` over one or more compose files. +# Catches interpolation errors, malformed YAML and unknown keys before +# they reach a deploy. +# +# Caller pattern: +# +# jobs: +# compose: +# uses: netresearch/.github/.github/workflows/lint-compose.yml@main +# with: +# compose-files: "compose.yml compose.prod.yml" +# env-substitutions: | +# MYSQL_ROOT_PASSWORD=placeholder +# APP_SECRET=placeholder +# +# Minimum (single compose.yml at repo root, .env.example copied if present): +# +# jobs: +# compose: +# uses: netresearch/.github/.github/workflows/lint-compose.yml@main +# +# DESIGN NOTES +# ============ +# `docker compose config` interpolates ${VAR} references from the process +# environment and a `.env` file in the working directory. Repos that ship +# a `.env.example` get it copied to `.env` (env-from-example) so committed +# placeholders resolve. `env-substitutions` then fills any still-empty +# `KEY=` assignments with a placeholder value, so validation passes WITHOUT +# real secrets — the value only has to be non-empty for interpolation to +# succeed. Each compose file is validated independently so a per-file parse +# error is attributed to the right file. +# +# SECURITY: pinned action SHAs, harden-runner, read-only permissions, +# `persist-credentials: false` on checkout. All caller strings are routed +# through `env:` (never interpolated into a `run:` body) to prevent shell +# injection (SonarCloud githubactions:S7630). + +name: Lint Compose (reusable) + +on: + workflow_call: + inputs: + runs-on: + description: "Runner label." + type: string + default: "ubuntu-latest" + timeout-minutes: + description: "Per-job timeout in minutes." + type: number + default: 5 + compose-files: + description: "Space-separated list of compose files. Each is validated independently with `docker compose -f config --quiet`." + type: string + default: "compose.yml" + env-from-example: + description: "Copy `.env.example` to `.env` (when the example file exists) before validating, so committed placeholders resolve." + type: boolean + default: true + env-substitutions: + description: >- + Newline-separated KEY=VALUE pairs. For each pair, an empty + assignment (`KEY=`) in `.env` is replaced with `KEY=VALUE`, so + `docker compose config` interpolation passes without real + secrets. The value only needs to be non-empty. + type: string + default: "" + +# CALLER REQUIREMENTS +# =================== +# When calling this reusable workflow, the caller's job-level +# `permissions:` block MUST grant at least this full set, otherwise +# the run fails with `startup_failure` before any job executes +# (GitHub rejects the workflow at startup when the reusable declares +# a permission the caller did not grant). NOTE: setting ANY scope in a +# `permissions:` block forces every UNLISTED scope to `none`, and a job +# that omits `permissions:` inherits the repository default +# (default_workflow_permissions) — so a repo hardened to `read` will +# startup-fail here unless these scopes are granted explicitly. +# +# jobs: +# lint-compose: +# uses: netresearch/.github/.github/workflows/lint-compose.yml@main +# permissions: +# contents: read +permissions: + contents: read + +jobs: + compose-config: + name: docker compose config + runs-on: ${{ inputs.runs-on }} + timeout-minutes: ${{ inputs.timeout-minutes }} + permissions: + contents: read + steps: + - name: Harden Runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Prepare .env + env: + # All caller-supplied strings pass through env vars (never inlined + # into the run body) — SonarCloud githubactions:S7630. + ENV_FROM_EXAMPLE: ${{ inputs.env-from-example }} + ENV_SUBSTITUTIONS: ${{ inputs.env-substitutions }} + run: | + set -euo pipefail + if [ "$ENV_FROM_EXAMPLE" = "true" ] && [ -f .env.example ]; then + cp .env.example .env + echo "Copied .env.example -> .env" + fi + if [ -n "$ENV_SUBSTITUTIONS" ]; then + touch .env + subs_file="$(mktemp)" + printf '%s\n' "$ENV_SUBSTITUTIONS" > "$subs_file" + # For each "KEY=VALUE" line, fill an empty "KEY=" assignment in + # .env with the placeholder value. awk exact-matches the line so + # VALUE metacharacters can't leak into a sed pattern. + while IFS= read -r sub; do + [ -z "$sub" ] && continue + case "$sub" in + *=*) ;; + *) continue ;; + esac + key="${sub%%=*}" + val="${sub#*=}" + tmp="$(mktemp)" + awk -v k="$key" -v v="$val" '$0 == k"=" { print k"="v; next } { print }' .env > "$tmp" + mv "$tmp" .env + done < "$subs_file" + rm -f "$subs_file" + fi + + - name: Validate compose configuration + env: + COMPOSE_FILES: ${{ inputs.compose-files }} + run: | + set -euo pipefail + docker compose version + read -ra files <<< "$COMPOSE_FILES" + if [ "${#files[@]}" -eq 0 ]; then + echo "::error::No compose files provided (input 'compose-files' is empty)." + exit 1 + fi + for f in "${files[@]}"; do + if [ ! -f "$f" ]; then + echo "::error::Compose file not found: $f" + exit 1 + fi + echo "::group::docker compose -f $f config --quiet" + docker compose -f "$f" config --quiet + echo "::endgroup::" + done diff --git a/docs/reusable-workflow-permissions.md b/docs/reusable-workflow-permissions.md index 5485b42..7582001 100644 --- a/docs/reusable-workflow-permissions.md +++ b/docs/reusable-workflow-permissions.md @@ -69,7 +69,8 @@ truth; this table is the index.) | `node-ci.yml` | `actions: read`, `contents: read`, `security-events: write`, `pull-requests: write` | | `python-app-ci.yml` | `actions: read`, `contents: read`, `security-events: write`, `pull-requests: write`, `id-token: write` | | `python-release.yml` | `contents: write`, `id-token: write` | -| `lint-*.yml` / `php-ci.yml` / `python-ci.yml` / `python-build.yml` / `python-audit.yml` / `ansible-lint.yml` / `ansible-molecule.yml` / `ts-check.yml` / `node-audit.yml` / `node-test.yml` / `node-build.yml` / `sonarqube.yml` / `smoke-test-container.yml` / `lint-container.yml` / `check-template-drift.yml` | `contents: read` | +| `docker-image-ci.yml` | `contents: read`, `packages: write`, `security-events: write`, `id-token: write`, `attestations: write`, `actions: read`, `pull-requests: write` | +| `lint-*.yml` / `php-ci.yml` / `python-ci.yml` / `python-build.yml` / `python-audit.yml` / `ansible-lint.yml` / `ansible-molecule.yml` / `ts-check.yml` / `node-audit.yml` / `node-test.yml` / `node-build.yml` / `lint-compose.yml` / `sonarqube.yml` / `smoke-test-container.yml` / `lint-container.yml` / `check-template-drift.yml` | `contents: read` | ### `netresearch/typo3-ci-workflows`