-
Notifications
You must be signed in to change notification settings - Fork 0
feat(workflows): reusable language CI + docker-publish + required aggregator #12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5a72372
6410acb
de56ed8
45e73ea
2dc6d66
fa215fc
6179e16
bffff5f
814b560
631d2ff
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| # Default owner for all files in resq-software/.github. | ||
| # Once a core team exists, point `*` to that team (e.g. `@resq-software/core`) | ||
| # and add narrower owners for specific paths. | ||
| * @WomB0ComB0 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| # Copyright 2026 ResQ Software | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # | ||
| # Reusable C++ CI — CMake configure + build + test. Matrix over OS. | ||
| # Pairs with security-scan.yml. | ||
| # | ||
| # Security: caller-controlled path/flag inputs are forwarded through | ||
| # `env:` and referenced as `"$VAR"` to prevent shell injection. | ||
|
|
||
| name: cpp-ci | ||
|
|
||
| on: | ||
| workflow_call: | ||
| inputs: | ||
| os-list: | ||
| type: string | ||
| required: false | ||
| default: '["ubuntu-latest","macos-latest","windows-latest"]' | ||
| source-dir: | ||
| type: string | ||
| required: false | ||
| default: "." | ||
| build-dir: | ||
| type: string | ||
| required: false | ||
| default: "build" | ||
| cmake-flags: | ||
| type: string | ||
| required: false | ||
| default: "" | ||
| build-config: | ||
| type: string | ||
| required: false | ||
| default: "Debug" | ||
| run-test: | ||
| type: boolean | ||
| required: false | ||
| default: true | ||
| timeout-minutes: | ||
| type: number | ||
| required: false | ||
| default: 30 | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| build-test: | ||
| name: ${{ matrix.os }} | ||
| runs-on: ${{ matrix.os }} | ||
| timeout-minutes: ${{ inputs.timeout-minutes }} | ||
| strategy: | ||
| fail-fast: false | ||
| matrix: | ||
| os: ${{ fromJSON(inputs.os-list) }} | ||
| steps: | ||
| - name: Harden Runner | ||
| if: runner.os == 'Linux' | ||
| uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2 | ||
| with: | ||
| egress-policy: audit | ||
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | ||
| - name: Configure | ||
| shell: bash | ||
| env: | ||
| SRC: ${{ inputs.source-dir }} | ||
| BUILD: ${{ inputs.build-dir }} | ||
| FLAGS: ${{ inputs.cmake-flags }} | ||
| # shellcheck disable=SC2086 - FLAGS is intentionally word-split into | ||
| # multiple cmake arguments; quoting would pass them as a single token. | ||
| run: | | ||
| # shellcheck disable=SC2086 | ||
| cmake -B "$BUILD" $FLAGS "$SRC" | ||
| - name: Build | ||
| shell: bash | ||
| env: | ||
| BUILD: ${{ inputs.build-dir }} | ||
| CONFIG: ${{ inputs.build-config }} | ||
| run: cmake --build "$BUILD" --config "$CONFIG" | ||
| - if: ${{ inputs.run-test }} | ||
| name: Test | ||
| shell: bash | ||
| env: | ||
| BUILD: ${{ inputs.build-dir }} | ||
| CONFIG: ${{ inputs.build-config }} | ||
| run: ctest --test-dir "$BUILD" --output-on-failure -C "$CONFIG" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,209 @@ | ||
| # Copyright 2026 ResQ Software | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # | ||
| # Reusable container build + publish + provenance workflow. | ||
| # Builds via buildx, pushes to ghcr.io by default, and produces an | ||
| # SLSA build-provenance attestation (sigstore-backed) for the image | ||
| # digest. | ||
| # | ||
| # Security posture: | ||
| # 1. The caller-supplied ref is checked out with | ||
| # `persist-credentials: false` so the git token is isolated from | ||
| # the fetched code. | ||
| # 2. All caller-controlled string inputs (image, registry, context, | ||
| # dockerfile, platforms, tags, build-args) are validated against | ||
| # allowlist regexes in a dedicated `Validate caller inputs` step, | ||
| # then re-exported to `$GITHUB_ENV` with a `SAFE_` prefix. | ||
| # Downstream steps reference `${{ env.SAFE_X }}` only — CodeQL | ||
| # treats SAFE_* as sanitized after explicit validation. | ||
| # 3. The caller-controlled `ref` input is the documented trust | ||
| # boundary; the actions/untrusted-checkout/medium alert is | ||
| # dismissed with rationale because this is a reusable-workflow | ||
| # model where the caller is a sibling resq-software/* repo. | ||
|
|
||
| name: docker-publish | ||
|
|
||
| on: | ||
| workflow_call: | ||
| inputs: | ||
| image: | ||
| description: Image name (without registry), e.g. "resq-software/mcp". | ||
| type: string | ||
| required: true | ||
| registry: | ||
| type: string | ||
| required: false | ||
| default: "ghcr.io" | ||
| context: | ||
| type: string | ||
| required: false | ||
| default: "." | ||
| dockerfile: | ||
| type: string | ||
| required: false | ||
| default: "Dockerfile" | ||
| platforms: | ||
| type: string | ||
| required: false | ||
| default: "linux/amd64,linux/arm64" | ||
| tags: | ||
| description: Newline-separated image tags to push (full refs). | ||
| type: string | ||
| required: true | ||
| build-args: | ||
| type: string | ||
| required: false | ||
| default: "" | ||
| push: | ||
| type: boolean | ||
| required: false | ||
| default: true | ||
| attest: | ||
| description: Emit SLSA build-provenance attestation (only runs when push is true). | ||
| type: boolean | ||
| required: false | ||
| default: true | ||
| ref: | ||
| description: > | ||
| Git ref to checkout. Caller's responsibility to pass a | ||
| trusted ref; persist-credentials:false is set on checkout | ||
| so the git token is not exposed. | ||
| type: string | ||
| required: false | ||
| default: "" | ||
| outputs: | ||
| image-digest: | ||
| description: > | ||
| sha256 digest of the pushed image manifest. Populated ONLY | ||
| when `push: true`. | ||
| value: ${{ jobs.publish.outputs.digest }} | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| publish: | ||
| name: Build, push, attest | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 30 | ||
| permissions: | ||
| contents: read | ||
| packages: write | ||
| id-token: write | ||
| attestations: write | ||
| outputs: | ||
| digest: ${{ steps.build.outputs.digest }} | ||
| steps: | ||
| - name: Harden Runner | ||
| uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2 | ||
| with: | ||
| egress-policy: audit | ||
|
|
||
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | ||
| with: | ||
| ref: ${{ inputs.ref || github.ref }} | ||
| persist-credentials: false | ||
|
|
||
| - name: Validate caller inputs | ||
| env: | ||
| IN_IMAGE: ${{ inputs.image }} | ||
| IN_REGISTRY: ${{ inputs.registry }} | ||
| IN_CONTEXT: ${{ inputs.context }} | ||
| IN_DOCKERFILE: ${{ inputs.dockerfile }} | ||
| IN_PLATFORMS: ${{ inputs.platforms }} | ||
| IN_TAGS: ${{ inputs.tags }} | ||
| IN_BUILD_ARGS: ${{ inputs.build-args }} | ||
| shell: bash | ||
| run: | | ||
| # shellcheck disable=SC2016,SC2129 | ||
| set -euo pipefail | ||
|
|
||
| fail() { echo "::error::$*" >&2; exit 1; } | ||
| match() { [[ "$2" =~ $3 ]] || fail "Invalid $1: '$2' does not match $3"; } | ||
|
|
||
| # image: namespace/name | ||
| match image "$IN_IMAGE" '^[a-zA-Z0-9][a-zA-Z0-9._/-]*$' | ||
| # registry: hostname with optional :port | ||
| match registry "$IN_REGISTRY" '^[a-zA-Z0-9][a-zA-Z0-9.:-]*$' | ||
|
|
||
| # context: local path or known remote URL form; no .. | ||
| 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 | ||
| fail "Invalid context: '$IN_CONTEXT'" | ||
| fi | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| case "$IN_CONTEXT" in *..*) fail "context may not contain '..'" ;; esac | ||
|
|
||
| # dockerfile: relative path, no traversal | ||
| match dockerfile "$IN_DOCKERFILE" '^[a-zA-Z0-9._][a-zA-Z0-9._/-]*$' | ||
| case "$IN_DOCKERFILE" in *..*) fail "dockerfile may not contain '..'" ;; esac | ||
|
|
||
| # platforms: comma-separated lowercase os/arch tokens | ||
| match platforms "$IN_PLATFORMS" '^[a-z0-9]+/[a-z0-9_-]+(,[a-z0-9]+/[a-z0-9_-]+)*$' | ||
|
|
||
| # tags: newline-separated image refs | ||
| while IFS= read -r tag; do | ||
| [ -z "$tag" ] && continue | ||
| match tag "$tag" '^[a-zA-Z0-9][a-zA-Z0-9._:/@-]*$' | ||
| done <<< "$IN_TAGS" | ||
|
|
||
| # build-args: KEY=VALUE per line; no backticks / $(; reject smuggling | ||
| while IFS= read -r arg; do | ||
| [ -z "$arg" ] && continue | ||
| [[ "$arg" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || fail "build-arg must be KEY=VALUE: '$arg'" | ||
| case "$arg" in | ||
| *'`'*) fail "build-arg may not contain backtick: '$arg'" ;; | ||
| *'$('*) fail "build-arg may not contain command substitution: '$arg'" ;; | ||
| esac | ||
| done <<< "$IN_BUILD_ARGS" | ||
|
|
||
| # Re-export sanitized values — CodeQL treats SAFE_* as workflow-controlled. | ||
| { | ||
| echo "SAFE_IMAGE=$IN_IMAGE" | ||
| echo "SAFE_REGISTRY=$IN_REGISTRY" | ||
| echo "SAFE_CONTEXT=$IN_CONTEXT" | ||
| echo "SAFE_DOCKERFILE=$IN_DOCKERFILE" | ||
| echo "SAFE_PLATFORMS=$IN_PLATFORMS" | ||
| } >> "$GITHUB_ENV" | ||
|
|
||
| { | ||
| echo "SAFE_TAGS<<__EOF__" | ||
| printf '%s\n' "$IN_TAGS" | ||
| echo "__EOF__" | ||
| } >> "$GITHUB_ENV" | ||
|
|
||
| { | ||
| echo "SAFE_BUILD_ARGS<<__EOF__" | ||
| printf '%s\n' "$IN_BUILD_ARGS" | ||
| echo "__EOF__" | ||
| } >> "$GITHUB_ENV" | ||
|
WomB0ComB0 marked this conversation as resolved.
Dismissed
|
||
|
|
||
| - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 | ||
| - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3 | ||
|
|
||
| - if: ${{ inputs.push }} | ||
| name: Log in to registry | ||
| uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 | ||
| with: | ||
| registry: ${{ env.SAFE_REGISTRY }} | ||
| username: ${{ github.actor }} | ||
| password: ${{ secrets.GITHUB_TOKEN }} | ||
|
Comment on lines
+182
to
+188
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Login credentials are hardcoded to the GHCR flow but
Either (a) document this workflow as GHCR-only and validate 🤖 Prompt for AI Agents |
||
|
|
||
| - name: Build and push | ||
| id: build | ||
| uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 | ||
| with: | ||
| context: ${{ env.SAFE_CONTEXT }} | ||
|
WomB0ComB0 marked this conversation as resolved.
Dismissed
|
||
| file: ${{ env.SAFE_DOCKERFILE }} | ||
| platforms: ${{ env.SAFE_PLATFORMS }} | ||
| tags: ${{ env.SAFE_TAGS }} | ||
| build-args: ${{ env.SAFE_BUILD_ARGS }} | ||
| push: ${{ inputs.push }} | ||
| provenance: mode=max | ||
| sbom: true | ||
|
|
||
| - if: ${{ inputs.attest && inputs.push }} | ||
| name: Attest build provenance | ||
| uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 | ||
| with: | ||
| subject-name: ${{ env.SAFE_REGISTRY }}/${{ env.SAFE_IMAGE }} | ||
| subject-digest: ${{ steps.build.outputs.digest }} | ||
| push-to-registry: true | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
$FLAGSword-splitting still allows caller-controlled argument injection into cmake.Routing
cmake-flagsthroughenv:prevents shell injection into the YAML-generated script (addressing the CodeQL finding), but$FLAGSis intentionally left unquoted so a caller can pass multiple flags. That means a caller can still inject arbitrary cmake arguments — e.g.-DCMAKE_CXX_COMPILER_LAUNCHER=...,-P <script>, or extra;-separated generator expressions — which on a reusable workflow invoked from a consumer repo is effectively arbitrary code execution on the runner via the build step.Since this is a reusable workflow consumed across the org, the trust boundary is "whatever the calling repo's workflow passes in", which for PRs from forks can be attacker-controlled. Consider one of:
cmake-flagsis trusted input and must not be derived from PR-event context in consumers.cmake-flagsas a JSON array and expanding viajq -r '.[]'into abasharray, preserving per-arg quoting:♻️ Array-based expansion
At minimum, add a comment to the workflow header making the trust assumption on
cmake-flagsexplicit.🤖 Prompt for AI Agents