From e5cdafbf1723a2b57a50276ca74367b02e95aa76 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Thu, 21 May 2026 23:35:18 +0200 Subject: [PATCH 1/3] feat(build-container): add 7 inputs for snipe-it-style matrix callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Phase 2 sub-agent migrating netresearch/snipe-it-docker-compose-stack to this reusable identified six gaps that prevented build.yml from delegating its build/push/sign/attest core. This commit adds them: target — multi-stage target stage (snipe-it: "runtime") build-args — newline-separated KEY=VALUE for Dockerfile ARGs (snipe-it: SNIPE_IT_VERSION, BUILD_DATE, VCS_REF, ROLLING_DEPS) cache-scope — GHA cache scope name. When set, both cache-from and cache-to suffix the scope so parallel matrix calls (snipe-it: track × composer) don't share a cache. Empty = single global cache (current backward-compatible default). provenance — buildx provenance mode passed to build-push-action (snipe-it: "mode=max"). Distinct from the existing `attest:` input which uses actions/attest-build- provenance for SLSA OUTside the image. sbom — boolean, in-image SBOM via build-push-action (off by default for backward compat). metadata-tags — caller-provided override for docker/metadata- action's tags: input. When set, REPLACES the reusable's default 5 patterns (ref-branch / ref-pr / semver-{version,M.m,M}). Snipe-it's bespoke tag fan-out (`:8.5.0` / `:8.5` / `:8` plus `-rolling` variants) goes here. metadata-labels — caller-provided OCI labels (newline-separated KEY=VALUE), APPENDED to the auto-generated set (created/revision/source/etc.). For image.title, image.description, image.licenses overrides. The default 5 tag patterns are now computed in a shell step gated on `metadata-tags == ''`, then passed to metadata-action as a step output. This keeps the action expression syntax simple while preserving exact backward compatibility for callers that don't override `metadata-tags`. cache-scope handling uses GitHub Actions expression `format()` with conditional inclusion — empty input produces `type=gha` and `type=gha,mode=max` (current behaviour); set input produces `type=gha,scope=` etc. Verified: actionlint clean on the modified file. The seventh input (SBOM) was added because sub-agent reported "provenance: mode=max / sbom: true" as a single gap — they're separate build-push-action inputs that snipe-it uses together. Companion: security-container.yml gets its own `tolerate-pull-failure` input in the next commit on this branch. Refs: netresearch/snipe-it-docker-compose-stack#11 (Phase 2 caller PR that surfaced these gaps). Signed-off-by: Sebastian Mendel --- .github/workflows/build-container.yml | 92 ++++++++++++++++++++++++--- 1 file changed, 82 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-container.yml b/.github/workflows/build-container.yml index fbd6363..10fad49 100644 --- a/.github/workflows/build-container.yml +++ b/.github/workflows/build-container.yml @@ -62,6 +62,41 @@ on: required: false type: string default: "" + target: + description: "Multi-stage build target / stage. Empty = build the default (last) stage." + required: false + type: string + default: "" + build-args: + description: "Newline-separated KEY=VALUE build args passed verbatim to docker/build-push-action. Use this when the Dockerfile expects ARGs (snipe-it: SNIPE_IT_VERSION, BUILD_DATE, VCS_REF, ROLLING_DEPS)." + required: false + type: string + default: "" + cache-scope: + description: "Optional GHA cache scope name. Set this when multiple parallel matrix calls share the same image-name but should keep their build cache separate (e.g. caller's matrix is track × composer-mode). Empty = share one global cache (current default behaviour)." + required: false + type: string + default: "" + provenance: + description: "Buildx provenance mode (passed to docker/build-push-action's `provenance:` input). Common values: empty (action default), 'mode=max', 'false'. Note: this is the in-image attestation, distinct from the SLSA attestation produced by `attest: true` (which uses actions/attest-build-provenance)." + required: false + type: string + default: "" + sbom: + description: "Generate in-image SBOM (passed to docker/build-push-action's `sbom:` input). Off by default." + required: false + type: boolean + default: false + metadata-tags: + description: "Caller-provided override for docker/metadata-action's `tags:` input. When set, REPLACES the reusable's default 5 patterns (ref-branch / ref-pr / semver-{version,major.minor,major}). Use this for callers with bespoke tag fan-out (e.g. snipe-it: `:8.5.0`, `:8.5`, `:8`, plus `-rolling` suffix variants)." + required: false + type: string + default: "" + metadata-labels: + description: "Caller-provided OCI labels (newline-separated KEY=VALUE), appended to the labels docker/metadata-action auto-generates (created/revision/source/etc.). Use this to set image.title, image.description, image.licenses overrides." + required: false + type: string + default: "" outputs: digest: description: "Digest of the pushed image (sha256:...). Empty when push=false." @@ -145,19 +180,48 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + - name: Compute default metadata tag patterns + if: inputs.metadata-tags == '' + id: default_patterns + env: + SEMVER_REF: ${{ inputs.ref || github.ref_name }} + REF_PROVIDED: ${{ inputs.ref != '' && 'true' || 'false' }} + IS_TAG_PUSH: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }} + run: | + set -euo pipefail + if [[ "$REF_PROVIDED" == "true" || "$IS_TAG_PUSH" == "true" ]]; then + EN_SEMVER=true + else + EN_SEMVER=false + fi + if [[ "$REF_PROVIDED" == "true" ]]; then + EN_REF=false + else + EN_REF=true + fi + { + echo 'patterns<> "$GITHUB_OUTPUT" + - name: Gather Docker metadata id: meta uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 - env: - SEMVER_REF: ${{ inputs.ref || github.ref_name }} with: images: ${{ env.IMAGE_REF }} - tags: | - type=ref,event=branch,enable=${{ !inputs.ref }} - type=ref,event=pr,enable=${{ !inputs.ref }} - type=semver,pattern={{version}},value=${{ env.SEMVER_REF }},enable=${{ inputs.ref != '' || startsWith(github.ref, 'refs/tags/') }} - type=semver,pattern={{major}}.{{minor}},value=${{ env.SEMVER_REF }},enable=${{ inputs.ref != '' || startsWith(github.ref, 'refs/tags/') }} - type=semver,pattern={{major}},value=${{ env.SEMVER_REF }},enable=${{ inputs.ref != '' || startsWith(github.ref, 'refs/tags/') }} + # Caller-supplied `metadata-tags` overrides the reusable's default 5 + # patterns entirely. Empty (default) → fall back to the default + # patterns computed in the step above. + tags: ${{ inputs.metadata-tags != '' && inputs.metadata-tags || steps.default_patterns.outputs.patterns }} + # Caller-supplied `metadata-labels` are appended to the auto- + # generated set (created/revision/source/etc.) rather than + # replacing them. + labels: ${{ inputs.metadata-labels }} - name: Log in to ${{ inputs.registry }} if: inputs.push @@ -176,12 +240,20 @@ jobs: with: context: ${{ env.BUILD_CONTEXT }} file: ${{ env.DOCKERFILE }} + target: ${{ inputs.target }} push: ${{ inputs.push }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: ${{ inputs.platforms }} - cache-from: type=gha - cache-to: type=gha,mode=max + build-args: ${{ inputs.build-args }} + provenance: ${{ inputs.provenance }} + sbom: ${{ inputs.sbom }} + # cache-scope: when set, both cache-from and cache-to suffix the GHA + # scope so parallel matrix calls keep separate caches. When empty, + # the GHA action uses a single default-scope cache (backwards + # compatible with the pre-cache-scope reusable). + cache-from: type=gha${{ inputs.cache-scope != '' && format(',scope={0}', inputs.cache-scope) || '' }} + cache-to: type=gha,mode=max${{ inputs.cache-scope != '' && format(',scope={0}', inputs.cache-scope) || '' }} - name: Run Trivy vulnerability scanner id: trivy From fa8c444681128a08bade010d580076744ca5507e Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Thu, 21 May 2026 23:36:02 +0200 Subject: [PATCH 2/3] feat(security-container): add tolerate-pull-failure input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the build-container.yml input expansion in e5cdafb. Surfaces a `tolerate-pull-failure` boolean input that gives the Trivy step `continue-on-error: true` when set. Off by default. Motivation: callers that fan a matrix over may-not-exist image tags (snipe-it's rolling-variant images, where composer audit can block the build for a specific matrix cell and leave no image to scan) need this semantic. Job-level `continue-on-error:` on the reusable-caller side is forbidden by GitHub — only step-level continue-on-error works, and only inside the reusable itself. When tolerate-pull-failure is true: - Trivy's pull failure (manifest unknown / 401 / image not found) is treated as a non-error and the job continues. - SARIF upload is gated on hashFiles('trivy-results.sarif') != '' (already in place), so the upload step skips cleanly when no file was produced. When false (default), the job correctly fails on a legitimate pull failure — backward compatible. Refs: netresearch/snipe-it-docker-compose-stack#11 (Phase 2 caller PR that surfaced this gap). Signed-off-by: Sebastian Mendel --- .github/workflows/security-container.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/security-container.yml b/.github/workflows/security-container.yml index faca950..3891a5f 100644 --- a/.github/workflows/security-container.yml +++ b/.github/workflows/security-container.yml @@ -89,6 +89,21 @@ on: description: "Upload SARIF to GitHub code-scanning. Disable when callers want raw Trivy text output only." type: boolean default: true + tolerate-pull-failure: + description: | + When true, the Trivy step is given `continue-on-error: true` — + the job stays green even if the image-ref can't be pulled + (e.g. `manifest unknown` because a rolling tag was never + published, or the image build for that matrix cell was + skipped). Workaround for callers fanning out a matrix over + tags where some cells point at images that may or may not + exist (snipe-it: rolling-variant images that the composer + audit blocked during the build phase). Cannot be expressed + via job-level `continue-on-error:` on the caller — GitHub + forbids that on reusable-caller jobs. Default false: a + legitimate pull failure correctly fails the job. + type: boolean + default: false permissions: contents: read @@ -136,6 +151,10 @@ jobs: password: ${{ github.token }} - name: Run Trivy vulnerability scanner + # `continue-on-error` is gated on the input — callers fanning a + # matrix over may-not-exist tags (rolling/optional builds) flip + # this to true. Default false: pull failures fail the job. + continue-on-error: ${{ inputs.tolerate-pull-failure }} uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: ${{ inputs.image-ref }} From b6cc0ac3b489b496c6ea5aa9d61817cfe56bcb72 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Fri, 22 May 2026 07:11:48 +0200 Subject: [PATCH 3/3] review(PR-143): refactor tolerate-pull-failure to a pre-flight check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot caught a real gap in fa8c444: putting `continue-on-error: true` directly on the Trivy step suppressed ALL Trivy failures, not just pull/manifest failures. A caller that set `exit-code: '1'` to make CVE findings fatal would silently get those findings swallowed by the same flag that was supposed to only tolerate "image doesn't exist". Refactored to a pre-flight pattern: 1. New "Pre-flight — image manifest exists" step (id: manifest_check) - Only runs when inputs.tolerate-pull-failure is true. - `docker manifest inspect ` is a cheap HEAD-style probe (no full layer pull). Succeeds → manifest available; fails → image not present in registry. - `continue-on-error: true` here is scoped to this single probe; it doesn't affect any other step. 2. Run Trivy step now gated on `steps.manifest_check.outcome != 'failure'`. - When tolerate-pull-failure is false (default), manifest_check is skipped — `outcome` is empty string, !='failure' → Trivy runs. (Backward compatible: no behavioral change for existing callers.) - When tolerate-pull-failure is true AND manifest exists: manifest_check succeeds → Trivy runs with NORMAL exit semantics. CVE findings + caller's `exit-code: '1'` still produce real failures. - When tolerate-pull-failure is true AND manifest missing: manifest_check fails (but the step's continue-on-error swallows it for job purposes); Trivy is skipped. Job exits green. The existing SARIF-upload gate `hashFiles('trivy-results.sarif') != ''` already handles the case where Trivy didn't run, so nothing else needed to change. Verified: actionlint clean. Refs: PR 143 comment from copilot-pull-request-reviewer (https://github.com/netresearch/.github/pull/143#discussion_r3285...). Signed-off-by: Sebastian Mendel --- .github/workflows/security-container.yml | 56 +++++++++++++++++------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/.github/workflows/security-container.yml b/.github/workflows/security-container.yml index 3891a5f..6060fac 100644 --- a/.github/workflows/security-container.yml +++ b/.github/workflows/security-container.yml @@ -91,17 +91,21 @@ on: default: true tolerate-pull-failure: description: | - When true, the Trivy step is given `continue-on-error: true` — - the job stays green even if the image-ref can't be pulled - (e.g. `manifest unknown` because a rolling tag was never - published, or the image build for that matrix cell was - skipped). Workaround for callers fanning out a matrix over - tags where some cells point at images that may or may not - exist (snipe-it: rolling-variant images that the composer - audit blocked during the build phase). Cannot be expressed - via job-level `continue-on-error:` on the caller — GitHub - forbids that on reusable-caller jobs. Default false: a - legitimate pull failure correctly fails the job. + When true, the workflow pre-flights `docker manifest inspect` + against the image-ref. If the manifest is unavailable + (`manifest unknown` because a rolling tag was never published, + or the image build for that matrix cell was skipped) the + Trivy + SARIF-upload steps are SKIPPED and the job exits + green. The Trivy step itself runs with normal (non- + suppressed) exit semantics, so legitimate CVE findings + any + caller-specified `exit-code: '1'` still produce a real + failure. Default false: a legitimate pull failure correctly + fails the job. Workaround for callers fanning out a matrix + over tags where some cells point at images that may or may + not exist (snipe-it: rolling-variant images that the + composer audit blocked during the build phase). Cannot be + expressed via job-level `continue-on-error:` on the caller + — GitHub forbids that on reusable-caller jobs. type: boolean default: false @@ -150,11 +154,33 @@ jobs: username: ${{ github.actor }} password: ${{ github.token }} + - name: Pre-flight — image manifest exists + # Only runs when the caller opts into tolerate-pull-failure. + # `docker manifest inspect` is a HEAD-style probe — no full pull — + # so it's cheap. The `continue-on-error: true` here ONLY affects + # this probe: a missing manifest does not fail the job; the next + # step's `if:` reads the outcome and decides whether to run Trivy. + # When tolerate-pull-failure is false (default), this step is + # skipped and the Trivy step's `if:` evaluates to true (no + # outcome to compare against → not 'failure' → runs normally). + id: manifest_check + if: inputs.tolerate-pull-failure + continue-on-error: true + env: + IMAGE_REF: ${{ inputs.image-ref }} + run: | + set -euo pipefail + docker manifest inspect "$IMAGE_REF" > /dev/null + echo "manifest available" + - name: Run Trivy vulnerability scanner - # `continue-on-error` is gated on the input — callers fanning a - # matrix over may-not-exist tags (rolling/optional builds) flip - # this to true. Default false: pull failures fail the job. - continue-on-error: ${{ inputs.tolerate-pull-failure }} + # Runs unless the pre-flight (if it ran) reported failure. + # No `continue-on-error` here: Trivy's own exit semantics + # (caller's `exit-code` input, vuln findings, etc.) propagate + # normally. This was the gap Copilot flagged on PR 141 — a + # broad continue-on-error swallowed legitimate finding-based + # failures. + if: steps.manifest_check.outcome != 'failure' uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: ${{ inputs.image-ref }}