name: Release Fuzz Gate
on:
pull_request:
branches:
- main
concurrency:
group: release-fuzz-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
fuzz:
name: Fuzz release candidate
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
env:
FUZZ_SECONDS: '60'
steps:
- name: Detect release PR
id: detect
run: |
if [[ "${{ github.head_ref }}" == release-plz-* ]]; then
echo "is_release=true" >> "$GITHUB_OUTPUT"
else
echo "is_release=false" >> "$GITHUB_OUTPUT"
echo "Not a release PR; skipping fuzzing (no-op pass)."
fi
- name: Checkout repository
if: ${{ steps.detect.outputs.is_release == 'true' }}
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Install Rust nightly
if: ${{ steps.detect.outputs.is_release == 'true' }}
uses: dtolnay/rust-toolchain@nightly
- name: Cache fuzz build
if: ${{ steps.detect.outputs.is_release == 'true' }}
uses: Swatinem/rust-cache@v2
with:
workspaces: fuzz
- name: Install cargo-fuzz
if: ${{ steps.detect.outputs.is_release == 'true' }}
run: cargo install cargo-fuzz --locked
- name: Run bounded fuzzing
id: fuzz
if: ${{ steps.detect.outputs.is_release == 'true' }}
run: |
set -euo pipefail
targets=(cnpj isin cfi country_code)
mkdir -p fuzz/reports
report=fuzz/reports/summary.md
overall=0
{
echo "## Release fuzz report"
echo
echo "Bounded fuzzing (\`${FUZZ_SECONDS}s\` per target) over the committed seed corpus."
echo
echo "| Target | Result | Execs | Duration |"
echo "| ------ | ------ | ----- | -------- |"
} > "$report"
for t in "${targets[@]}"; do
echo "::group::Fuzzing $t"
mkdir -p "fuzz/corpus/$t"
log="fuzz/reports/$t.log"
start=$(date +%s)
status="pass"
if ! cargo +nightly fuzz run "$t" "fuzz/corpus/$t" "fuzz/seeds/$t" \
-- -dict="fuzz/dict/$t.dict" -max_total_time="${FUZZ_SECONDS}" \
> "$log" 2>&1; then
status="fail"
overall=1
fi
end=$(date +%s)
elapsed=$((end - start))
# Extract the last reported total exec count from libFuzzer output (best effort).
execs=$(grep -oE '#[0-9]+' "$log" | tail -n1 | tr -d '#' || true)
execs=${execs:-n/a}
if [ "$status" = "pass" ]; then
echo "| \`$t\` | ✅ pass | $execs | ${elapsed}s |" >> "$report"
else
echo "| \`$t\` | ❌ **fail** | $execs | ${elapsed}s |" >> "$report"
{
echo
echo "<details><summary>Failure output: <code>$t</code></summary>"
echo
echo '```'
tail -n 40 "$log"
echo '```'
echo
echo "</details>"
} >> "$report"
fi
echo "::endgroup::"
done
{
echo
if [ "$overall" -eq 0 ]; then
echo "**All targets passed.** No crashes found within the time budget."
else
echo "**Fuzzing found a failure.** Do not merge this release PR until resolved."
fi
} >> "$report"
# Publish to the job summary.
cat "$report" >> "$GITHUB_STEP_SUMMARY"
echo "overall=$overall" >> "$GITHUB_OUTPUT"
- name: Comment report on PR
if: ${{ always() && steps.detect.outputs.is_release == 'true' }}
uses: actions/github-script@v9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const marker = '<!-- release-fuzz-report -->';
let body;
try {
body = fs.readFileSync('fuzz/reports/summary.md', 'utf8');
} catch (e) {
body = '## Release fuzz report\n\nThe fuzzing step did not produce a report (it may have failed to start).';
}
body = `${marker}\n${body}`;
const { owner, repo } = context.repo;
const issue_number = context.issue.number;
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number, body });
}
- name: Upload crash artifacts
if: ${{ failure() && steps.detect.outputs.is_release == 'true' }}
uses: actions/upload-artifact@v7
with:
name: fuzz-artifacts
path: |
fuzz/artifacts/**
fuzz/reports/**
if-no-files-found: ignore
- name: Fail if fuzzing found a crash
if: ${{ steps.detect.outputs.is_release == 'true' && steps.fuzz.outputs.overall != '0' }}
run: exit 1