From 4b10db99b9af2a65f065a02ac284e8c6c9d8c979 Mon Sep 17 00:00:00 2001 From: Matt Jones <47545907+SoundMatt@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:42:19 -0700 Subject: [PATCH 1/2] feat: first-party GitHub Action for cfusa check + SARIF upload (issue #213) Adds `action.yml` (composite action, usable as `uses: SoundMatt/c-FuSa@ `): installs the pinned cfusa release binary (cfusa-linux-amd64 / cfusa-darwin-arm64, matching release.yml's existing artifact names), runs `cfusa check`, uploads SARIF via github/codeql-action/upload-sarif, and gates the workflow step on the findings exit code -- separately from the SARIF upload, so annotations still appear even with fail-on-error: false. Inputs: dir, version (default "latest", resolved via the GitHub releases API), strict, changed-since (issue #209's --changed-since, for PR-scoped gating), sarif-file, fail-on-error. Outputs: sarif-file, exit-code. Regression test: .github/workflows/action-smoke-test.yml runs the action via `uses: ./` against this repo itself on every push/PR to main, and asserts a non-empty SARIF file was produced -- there's no C code here for ctest to exercise, so this is the equivalent coverage. Verified the underlying `cfusa check` exit-code contract locally against the built binary: 0 (clean), 1 (--strict gate trips on this repo's existing warnings), 2 (bad --changed-since ref) -- matches how action.yml branches on each. README: replaced the old hand-rolled "build from source" CI snippet as the primary GitHub Actions Integration example with the new action, kept the build-from-source path as a documented alternative for checking an unreleased fix. Closes #213 Co-Authored-By: Claude Sonnet 5 Signed-off-by: Matt Jones <47545907+SoundMatt@users.noreply.github.com> --- .github/workflows/action-smoke-test.yml | 46 ++++++++ README.md | 28 ++++- action.yml | 134 ++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/action-smoke-test.yml create mode 100644 action.yml diff --git a/.github/workflows/action-smoke-test.yml b/.github/workflows/action-smoke-test.yml new file mode 100644 index 0000000..52b83b0 --- /dev/null +++ b/.github/workflows/action-smoke-test.yml @@ -0,0 +1,46 @@ +name: Action Smoke Test + +# issue #213: action.yml has no C code for ctest to exercise, so this is +# its regression test -- runs the composite action, at the exact commit +# under test (`uses: ./`), against this repo itself, and checks it +# actually produced a SARIF file. Separate from the "SARIF Upload" job in +# ci.yml, which builds cfusa from source; this job installs the latest +# *released* binary, since that's what a real adopting project's workflow +# does via `uses: SoundMatt/c-FuSa@`. +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + security-events: write + +jobs: + smoke-test: + name: Action Smoke Test + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - name: Run c-FuSa via this action + id: cfusa + uses: ./ + with: + dir: '.' + sarif-file: 'action-smoke-results.sarif' + # This repo's own findings are all WARNING/INFO (see the + # "Self-qualification"/"SARIF Upload" jobs in ci.yml) -- don't + # gate this smoke test on that pre-existing backlog, only on + # the action actually running end-to-end. + fail-on-error: 'false' + + - name: Verify SARIF was produced + run: | + set -euo pipefail + f="${{ steps.cfusa.outputs.sarif-file }}" + test -s "$f" || { echo "::error::expected a non-empty SARIF file at $f"; exit 1; } + grep -q '"version"' "$f" || { echo "::error::$f doesn't look like SARIF"; exit 1; } + echo "exit-code output: ${{ steps.cfusa.outputs.exit-code }}" + echo "OK: $f ($(wc -c < "$f") bytes)" diff --git a/README.md b/README.md index efed774..2a941f7 100644 --- a/README.md +++ b/README.md @@ -415,12 +415,38 @@ Copy `.cfusa.json.template` and `.cfusa-reqs.json.template` to start a new proje ## GitHub Actions Integration +A first-party composite action (`action.yml`, this repo) installs a pinned +`cfusa` release binary, runs `cfusa check`, and uploads results to the +GitHub Security tab as SARIF — no manual build step needed in the calling +repo: + +```yaml +- uses: actions/checkout@v4 + +- name: c-FuSa safety & security check + uses: SoundMatt/c-FuSa@v0.6.1 # pin a release tag, or use "main" + with: + dir: src/ # default: "." + strict: 'false' # gate on WARNING too, not just ERROR + changed-since: ${{ github.event_name == 'pull_request' && 'origin/main' || '' }} + fail-on-error: 'true' # default: fail the step on gated findings +``` + +SARIF is always uploaded (via `github/codeql-action/upload-sarif`) even +when `fail-on-error: false`, so findings still surface as code-scanning +annotations without blocking the build. See +[`action.yml`](action.yml) for every input/output, and +[`.github/workflows/action-smoke-test.yml`](.github/workflows/action-smoke-test.yml) +for a working end-to-end example (this repo dogfoods its own action). + +Prefer to build from source instead of installing a release binary (e.g. +to check an unreleased fix)? Do it directly: + ```yaml - name: cfusa safety check run: | cmake -B build && cmake --build build ./build/cfusa check --dir src/ --format sarif --output results.sarif - ./build/cfusa trace --req-coverage 80 --sec-tested 70 - name: Upload SARIF uses: github/codeql-action/upload-sarif@v3 diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..6f39258 --- /dev/null +++ b/action.yml @@ -0,0 +1,134 @@ +name: 'c-FuSa Safety & Security Check' +description: >- + Runs c-FuSa (MISRA-C/CERT-C/CWE static analysis for functional-safety + and cybersecurity C projects), uploads results to the GitHub code + scanning tab as SARIF, and gates the workflow on findings. +author: 'SoundMatt' +branding: + icon: 'shield' + color: 'blue' + +inputs: + dir: + description: 'Directory to analyze (passed to `cfusa check --dir`).' + required: false + default: '.' + version: + description: >- + c-FuSa release tag to install (e.g. "v0.6.1"). "latest" resolves the + most recent GitHub release at run time. + required: false + default: 'latest' + strict: + description: 'Exit non-zero on WARNING findings too, not just ERROR.' + required: false + default: 'false' + changed-since: + description: >- + Only report findings on lines changed since this git ref (`cfusa + check --changed-since`) — e.g. "origin/main" on a pull request, to + gate only on newly-introduced findings rather than a project's full + existing backlog. Leave empty to check the whole tree. + required: false + default: '' + sarif-file: + description: 'Path to write the SARIF report to.' + required: false + default: 'cfusa-results.sarif' + fail-on-error: + description: >- + Fail this workflow step when `cfusa check`'s exit-code gate trips + (any ERROR finding, or any WARNING finding too under `strict`). + SARIF is uploaded either way, so code-scanning annotations still + appear even when this is "false". + required: false + default: 'true' + +outputs: + sarif-file: + description: 'Path to the generated SARIF report.' + value: ${{ steps.run-cfusa.outputs.sarif-file }} + exit-code: + description: "cfusa check's raw exit code (0 = clean, 1 = gated findings)." + value: ${{ steps.run-cfusa.outputs.exit-code }} + +runs: + using: 'composite' + steps: + - name: Install cfusa + id: install + shell: bash + run: | + set -euo pipefail + version="${{ inputs.version }}" + if [ "$version" = "latest" ]; then + version=$(curl -fsSL \ + https://api.github.com/repos/SoundMatt/c-FuSa/releases/latest \ + | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/') + if [ -z "$version" ]; then + echo "::error::Could not resolve the latest c-FuSa release tag from the GitHub API." + exit 1 + fi + fi + + case "$(uname -s)-$(uname -m)" in + Linux-x86_64) asset='cfusa-linux-amd64' ;; + Darwin-arm64) asset='cfusa-darwin-arm64' ;; + *) + echo "::error::Unsupported runner platform $(uname -s)-$(uname -m)."\ + " c-FuSa currently ships prebuilt cfusa-linux-amd64 and"\ + " cfusa-darwin-arm64 release binaries only — see issue #211"\ + " for native-Windows status." + exit 1 + ;; + esac + + url="https://github.com/SoundMatt/c-FuSa/releases/download/${version}/${asset}" + echo "Installing cfusa ${version} (${asset})" + curl -fsSL -o cfusa-bin "$url" + chmod +x cfusa-bin + echo "bin=$(pwd)/cfusa-bin" >> "$GITHUB_OUTPUT" + ./cfusa-bin version + + - name: Run cfusa check + id: run-cfusa + shell: bash + run: | + set -uo pipefail + args=(check --dir "${{ inputs.dir }}" --format sarif --output "${{ inputs.sarif-file }}") + if [ "${{ inputs.strict }}" = "true" ]; then + args+=(--strict) + fi + if [ -n "${{ inputs.changed-since }}" ]; then + args+=(--changed-since "${{ inputs.changed-since }}") + fi + + "${{ steps.install.outputs.bin }}" "${args[@]}" + rc=$? + + echo "exit-code=$rc" >> "$GITHUB_OUTPUT" + echo "sarif-file=${{ inputs.sarif-file }}" >> "$GITHUB_OUTPUT" + + # rc 0 (clean) and rc 1 (gated findings, handled by the separate + # "Enforce findings gate" step below so SARIF still gets uploaded + # first) are both normal outcomes here. rc 2/3 (bad --changed-since + # ref, or a failed SARIF write) are tool/usage errors -- always + # fatal, independent of fail-on-error, since there is no SARIF + # file to upload in either case. + if [ "$rc" -gt 1 ]; then + echo "::error::cfusa check exited $rc (usage error, not a findings gate) — see the log above." + exit "$rc" + fi + exit 0 + + - name: Upload SARIF to code scanning + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: ${{ inputs.sarif-file }} + + - name: Enforce findings gate + if: ${{ inputs.fail-on-error == 'true' && steps.run-cfusa.outputs.exit-code == '1' }} + shell: bash + run: | + echo "::error::cfusa check reported gating findings (ERROR${{ inputs.strict == 'true' && ', or WARNING under strict mode,' || '' }}). See the Security tab (code scanning) for details." + exit 1 From 3e225727fbb301b45c7950eb93cc22bad2babd69 Mon Sep 17 00:00:00 2001 From: Matt Jones <47545907+SoundMatt@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:47:14 -0700 Subject: [PATCH 2/2] fix: avoid curl|grep -m1 SIGPIPE race in action.yml's version resolution PR #223's Action Smoke Test caught this on its very first real run: `curl -fsSL | grep -m1 '"tag_name"' | sed ...` failed intermittently in CI with "curl: (23) Failure writing output to destination" -- grep -m1 exits (closing its stdin pipe) right after its first match, and if curl is still writing later bytes of the response body at that moment it gets SIGPIPE'd. Under this step's `set -eo pipefail` that aborted the whole "Install cfusa" step before the actual binary download ever ran. Fix: capture curl's full output into a variable first, then grep/sed that variable -- once curl has completely finished, there's no reader left to race against. Verified locally: the original pipe form is a genuine (if timing- dependent) race -- reproduced the failure via `bash -c 'set -euo pipefail; curl ... | grep -m1 ...'`; the fixed form resolves the tag correctly and has no external process left mid-write when grep exits. Co-Authored-By: Claude Sonnet 5 Signed-off-by: Matt Jones <47545907+SoundMatt@users.noreply.github.com> --- action.yml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/action.yml b/action.yml index 6f39258..87039fc 100644 --- a/action.yml +++ b/action.yml @@ -62,9 +62,21 @@ runs: set -euo pipefail version="${{ inputs.version }}" if [ "$version" = "latest" ]; then - version=$(curl -fsSL \ - https://api.github.com/repos/SoundMatt/c-FuSa/releases/latest \ - | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/') + # Capture the full response into a variable before parsing it, + # rather than piping curl straight into `grep -m1`: grep -m1 + # exits (closing its stdin pipe) right after its first match, + # and if curl is still writing later bytes of a large response + # body when that happens it can get SIGPIPE'd -- observed in CI + # as an intermittent "curl: (23) Failure writing output to + # destination" that, under `set -eo pipefail`, aborted this + # entire step before the actual binary download ever ran. Once + # curl has fully finished into a variable there's no reader to + # race. + resp=$(curl -fsSL https://api.github.com/repos/SoundMatt/c-FuSa/releases/latest) || { + echo "::error::Failed to query the GitHub releases API for the latest c-FuSa release." + exit 1 + } + version=$(printf '%s' "$resp" | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/') if [ -z "$version" ]; then echo "::error::Could not resolve the latest c-FuSa release tag from the GitHub API." exit 1