|
| 1 | +# Copyright 2026 ResQ Software |
| 2 | +# SPDX-License-Identifier: Apache-2.0 |
| 3 | +# |
| 4 | +# Reusable container build + publish + provenance workflow. |
| 5 | +# Builds via buildx, pushes to ghcr.io by default, and produces an |
| 6 | +# SLSA build-provenance attestation (sigstore-backed) for the image |
| 7 | +# digest. |
| 8 | +# |
| 9 | +# Security posture: |
| 10 | +# 1. The caller-supplied ref is checked out with |
| 11 | +# `persist-credentials: false` so the git token is isolated from |
| 12 | +# the fetched code. |
| 13 | +# 2. All caller-controlled string inputs (image, registry, context, |
| 14 | +# dockerfile, platforms, tags, build-args) are validated against |
| 15 | +# allowlist regexes in a dedicated `Validate caller inputs` step, |
| 16 | +# then re-exported to `$GITHUB_ENV` with a `SAFE_` prefix. |
| 17 | +# Downstream steps reference `${{ env.SAFE_X }}` only — CodeQL |
| 18 | +# treats SAFE_* as sanitized after explicit validation. |
| 19 | +# 3. The caller-controlled `ref` input is the documented trust |
| 20 | +# boundary; the actions/untrusted-checkout/medium alert is |
| 21 | +# dismissed with rationale because this is a reusable-workflow |
| 22 | +# model where the caller is a sibling resq-software/* repo. |
| 23 | + |
| 24 | +name: docker-publish |
| 25 | + |
| 26 | +on: |
| 27 | + workflow_call: |
| 28 | + inputs: |
| 29 | + image: |
| 30 | + description: Image name (without registry), e.g. "resq-software/mcp". |
| 31 | + type: string |
| 32 | + required: true |
| 33 | + registry: |
| 34 | + type: string |
| 35 | + required: false |
| 36 | + default: "ghcr.io" |
| 37 | + context: |
| 38 | + type: string |
| 39 | + required: false |
| 40 | + default: "." |
| 41 | + dockerfile: |
| 42 | + type: string |
| 43 | + required: false |
| 44 | + default: "Dockerfile" |
| 45 | + platforms: |
| 46 | + type: string |
| 47 | + required: false |
| 48 | + default: "linux/amd64,linux/arm64" |
| 49 | + tags: |
| 50 | + description: Newline-separated image tags to push (full refs). |
| 51 | + type: string |
| 52 | + required: true |
| 53 | + build-args: |
| 54 | + type: string |
| 55 | + required: false |
| 56 | + default: "" |
| 57 | + push: |
| 58 | + type: boolean |
| 59 | + required: false |
| 60 | + default: true |
| 61 | + attest: |
| 62 | + description: Emit SLSA build-provenance attestation (only runs when push is true). |
| 63 | + type: boolean |
| 64 | + required: false |
| 65 | + default: true |
| 66 | + ref: |
| 67 | + description: > |
| 68 | + Git ref to checkout. Caller's responsibility to pass a |
| 69 | + trusted ref; persist-credentials:false is set on checkout |
| 70 | + so the git token is not exposed. |
| 71 | + type: string |
| 72 | + required: false |
| 73 | + default: "" |
| 74 | + outputs: |
| 75 | + image-digest: |
| 76 | + description: > |
| 77 | + sha256 digest of the pushed image manifest. Populated ONLY |
| 78 | + when `push: true`. |
| 79 | + value: ${{ jobs.publish.outputs.digest }} |
| 80 | + |
| 81 | +permissions: |
| 82 | + contents: read |
| 83 | + |
| 84 | +jobs: |
| 85 | + publish: |
| 86 | + name: Build, push, attest |
| 87 | + runs-on: ubuntu-latest |
| 88 | + timeout-minutes: 30 |
| 89 | + permissions: |
| 90 | + contents: read |
| 91 | + packages: write |
| 92 | + id-token: write |
| 93 | + attestations: write |
| 94 | + outputs: |
| 95 | + digest: ${{ steps.build.outputs.digest }} |
| 96 | + steps: |
| 97 | + - name: Harden Runner |
| 98 | + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2 |
| 99 | + with: |
| 100 | + egress-policy: audit |
| 101 | + |
| 102 | + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 |
| 103 | + with: |
| 104 | + ref: ${{ inputs.ref || github.ref }} |
| 105 | + persist-credentials: false |
| 106 | + |
| 107 | + - name: Validate caller inputs |
| 108 | + env: |
| 109 | + IN_IMAGE: ${{ inputs.image }} |
| 110 | + IN_REGISTRY: ${{ inputs.registry }} |
| 111 | + IN_CONTEXT: ${{ inputs.context }} |
| 112 | + IN_DOCKERFILE: ${{ inputs.dockerfile }} |
| 113 | + IN_PLATFORMS: ${{ inputs.platforms }} |
| 114 | + IN_TAGS: ${{ inputs.tags }} |
| 115 | + IN_BUILD_ARGS: ${{ inputs.build-args }} |
| 116 | + shell: bash |
| 117 | + run: | |
| 118 | + # shellcheck disable=SC2016,SC2129 |
| 119 | + set -euo pipefail |
| 120 | +
|
| 121 | + fail() { echo "::error::$*" >&2; exit 1; } |
| 122 | + match() { [[ "$2" =~ $3 ]] || fail "Invalid $1: '$2' does not match $3"; } |
| 123 | +
|
| 124 | + # image: namespace/name |
| 125 | + match image "$IN_IMAGE" '^[a-zA-Z0-9][a-zA-Z0-9._/-]*$' |
| 126 | + # registry: hostname with optional :port |
| 127 | + match registry "$IN_REGISTRY" '^[a-zA-Z0-9][a-zA-Z0-9.:-]*$' |
| 128 | +
|
| 129 | + # context: local path or known remote URL form; no .. |
| 130 | + if ! [[ "$IN_CONTEXT" =~ ^(\.|[a-zA-Z0-9._][a-zA-Z0-9._/-]*|https?://[a-zA-Z0-9._:/?#=\&%-]+|git://[a-zA-Z0-9._:/?#=\&%-]+|github\.com/[a-zA-Z0-9._/-]+)$ ]]; then |
| 131 | + fail "Invalid context: '$IN_CONTEXT'" |
| 132 | + fi |
| 133 | + case "$IN_CONTEXT" in *..*) fail "context may not contain '..'" ;; esac |
| 134 | +
|
| 135 | + # dockerfile: relative path, no traversal |
| 136 | + match dockerfile "$IN_DOCKERFILE" '^[a-zA-Z0-9._][a-zA-Z0-9._/-]*$' |
| 137 | + case "$IN_DOCKERFILE" in *..*) fail "dockerfile may not contain '..'" ;; esac |
| 138 | +
|
| 139 | + # platforms: comma-separated lowercase os/arch tokens |
| 140 | + match platforms "$IN_PLATFORMS" '^[a-z0-9]+/[a-z0-9_-]+(,[a-z0-9]+/[a-z0-9_-]+)*$' |
| 141 | +
|
| 142 | + # tags: newline-separated image refs |
| 143 | + while IFS= read -r tag; do |
| 144 | + [ -z "$tag" ] && continue |
| 145 | + match tag "$tag" '^[a-zA-Z0-9][a-zA-Z0-9._:/@-]*$' |
| 146 | + done <<< "$IN_TAGS" |
| 147 | +
|
| 148 | + # build-args: KEY=VALUE per line; no backticks / $(; reject smuggling |
| 149 | + while IFS= read -r arg; do |
| 150 | + [ -z "$arg" ] && continue |
| 151 | + [[ "$arg" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || fail "build-arg must be KEY=VALUE: '$arg'" |
| 152 | + case "$arg" in |
| 153 | + *'`'*) fail "build-arg may not contain backtick: '$arg'" ;; |
| 154 | + *'$('*) fail "build-arg may not contain command substitution: '$arg'" ;; |
| 155 | + esac |
| 156 | + done <<< "$IN_BUILD_ARGS" |
| 157 | +
|
| 158 | + # Re-export sanitized values — CodeQL treats SAFE_* as workflow-controlled. |
| 159 | + { |
| 160 | + echo "SAFE_IMAGE=$IN_IMAGE" |
| 161 | + echo "SAFE_REGISTRY=$IN_REGISTRY" |
| 162 | + echo "SAFE_CONTEXT=$IN_CONTEXT" |
| 163 | + echo "SAFE_DOCKERFILE=$IN_DOCKERFILE" |
| 164 | + echo "SAFE_PLATFORMS=$IN_PLATFORMS" |
| 165 | + } >> "$GITHUB_ENV" |
| 166 | +
|
| 167 | + { |
| 168 | + echo "SAFE_TAGS<<__EOF__" |
| 169 | + printf '%s\n' "$IN_TAGS" |
| 170 | + echo "__EOF__" |
| 171 | + } >> "$GITHUB_ENV" |
| 172 | +
|
| 173 | + { |
| 174 | + echo "SAFE_BUILD_ARGS<<__EOF__" |
| 175 | + printf '%s\n' "$IN_BUILD_ARGS" |
| 176 | + echo "__EOF__" |
| 177 | + } >> "$GITHUB_ENV" |
| 178 | +
|
| 179 | + - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 |
| 180 | + - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3 |
| 181 | + |
| 182 | + - if: ${{ inputs.push }} |
| 183 | + name: Log in to registry |
| 184 | + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 |
| 185 | + with: |
| 186 | + registry: ${{ env.SAFE_REGISTRY }} |
| 187 | + username: ${{ github.actor }} |
| 188 | + password: ${{ secrets.GITHUB_TOKEN }} |
| 189 | + |
| 190 | + - name: Build and push |
| 191 | + id: build |
| 192 | + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 |
| 193 | + with: |
| 194 | + context: ${{ env.SAFE_CONTEXT }} |
| 195 | + file: ${{ env.SAFE_DOCKERFILE }} |
| 196 | + platforms: ${{ env.SAFE_PLATFORMS }} |
| 197 | + tags: ${{ env.SAFE_TAGS }} |
| 198 | + build-args: ${{ env.SAFE_BUILD_ARGS }} |
| 199 | + push: ${{ inputs.push }} |
| 200 | + provenance: mode=max |
| 201 | + sbom: true |
| 202 | + |
| 203 | + - if: ${{ inputs.attest && inputs.push }} |
| 204 | + name: Attest build provenance |
| 205 | + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 |
| 206 | + with: |
| 207 | + subject-name: ${{ env.SAFE_REGISTRY }}/${{ env.SAFE_IMAGE }} |
| 208 | + subject-digest: ${{ steps.build.outputs.digest }} |
| 209 | + push-to-registry: true |
0 commit comments