name: CI
on:
push:
branches: [main]
paths-ignore: ['docs/site/**']
pull_request:
branches: [main]
workflow_dispatch:
inputs:
dry_run:
description: 'Dry run'
type: boolean
default: false
permissions:
contents: read
jobs:
test:
name: Test
if: github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'chore(release):')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - uses: taiki-e/install-action@67729d5c413db75907f0ad1e39bb04b9c868ff60 with:
tool: cargo-nextest
- name: Format check
run: cargo fmt --check
- name: Clippy
run: cargo clippy -- -D warnings
- name: Check the no-cli build surface
run: cargo check -p ferrflow-wasm
- name: Tests (nextest)
run: cargo nextest run --no-fail-fast
security:
name: Cargo Security
if: github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'chore(release):')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
fetch-depth: 0
persist-credentials: false
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - uses: taiki-e/install-action@67729d5c413db75907f0ad1e39bb04b9c868ff60 with:
tool: cargo-audit,cargo-deny,cargo-machete,cargo-vet
- name: cargo audit (RustSec advisories)
run: cargo audit --deny warnings
- name: cargo deny (licenses + bans + sources)
run: cargo deny --all-features check
- name: cargo machete (unused deps)
run: cargo machete
- name: Is this a lockfile-only refresh
id: vet_scope
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.user.login == 'ferrlabs-renovate[bot]'
env:
BASE: ${{ github.event.pull_request.base.sha }}
run: |
changed=$(git diff --name-only "$BASE...HEAD")
if echo "$changed" | grep -qE '(^|/)Cargo\.toml$'; then
echo "advisory=false" >> "$GITHUB_OUTPUT"
elif echo "$changed" | grep -qE '(^|/)Cargo\.lock$'; then
echo "advisory=true" >> "$GITHUB_OUTPUT"
else
echo "advisory=false" >> "$GITHUB_OUTPUT"
fi
- name: cargo vet (supply-chain audit)
id: vet
continue-on-error: ${{ steps.vet_scope.outputs.advisory == 'true' }}
run: cargo vet
- name: Record an advisory cargo vet failure
if: steps.vet.outcome == 'failure' && steps.vet_scope.outputs.advisory == 'true'
run: |
{
echo '### cargo vet did not pass, and did not fail the build'
echo
echo 'This is a Renovate pull request that moves `Cargo.lock` without touching any'
echo '`Cargo.toml`, where the step is advisory. It failed; the log above holds the'
echo 'reason. Usually that is the refreshed crates being uncertified, but the'
echo 'exemption is not conditional on the reason, so read the output before assuming'
echo 'it is routine.'
echo
echo 'It still fails the build on `main`, on any pull request that edits a'
echo '`Cargo.toml`, and on anything not opened by the bot. See FerrLabs/FerrFlow#1017.'
} >> "$GITHUB_STEP_SUMMARY"
build:
name: Build Release Binary
needs: test
if: github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'chore(release):')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - name: Build ferrflow
run: cargo build --release
- name: Upload binary
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: ferrflow-binary
path: target/release/ferrflow
retention-days: 1
fixture-generate:
name: Generate Fixtures
needs: test
if: github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'chore(release):')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Cache generated fixtures
id: fixture-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with:
path: fixtures-generated
key: fixtures-${{ hashFiles('tests/fixtures/definitions/**') }}
- name: Generate fixtures
if: steps.fixture-cache.outputs.cache-hit != 'true'
uses: FerrLabs/Fixtures@1a96407b500631870c766cbe117555db19167dc9 with:
definitions: tests/fixtures/definitions
- name: Resolve generated path
id: path
run: |
if [ -d "fixtures-generated" ]; then
echo "dir=fixtures-generated" >> "$GITHUB_OUTPUT"
else
for d in /tmp/tmp.*/generated; do
if [ -d "$d" ]; then
cp -r "$d" fixtures-generated
echo "dir=fixtures-generated" >> "$GITHUB_OUTPUT"
break
fi
done
fi
- name: Upload generated fixtures
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: fixtures-generated
path: fixtures-generated/
include-hidden-files: true
retention-days: 1
fixture-monorepo:
name: Fixtures — Monorepo
needs: [build, fixture-generate]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: ferrflow-binary
path: bin/
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: fixtures-generated
path: fixtures-generated/
- name: Run monorepo fixture tests
env:
FERRFLOW_BIN: ./bin/ferrflow
DIFF_DIR: fixture-diffs
run: |
chmod +x ./bin/ferrflow
mkdir -p filtered-fixtures
for d in fixtures-generated/monorepo-*; do
[ -d "$d" ] && cp -r "$d" filtered-fixtures/
done
bash tests/fixtures/run-tests.sh filtered-fixtures
- name: Upload snapshot diffs
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: fixture-diffs-monorepo
path: fixture-diffs/
retention-days: 7
if-no-files-found: ignore
fixture-single:
name: Fixtures — Single Package
needs: [build, fixture-generate]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: ferrflow-binary
path: bin/
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: fixtures-generated
path: fixtures-generated/
- name: Run single-package fixture tests
env:
FERRFLOW_BIN: ./bin/ferrflow
DIFF_DIR: fixture-diffs
run: |
chmod +x ./bin/ferrflow
mkdir -p filtered-fixtures
for d in fixtures-generated/single-* fixtures-generated/multiple-* fixtures-generated/no-tags-initial-* fixtures-generated/no-versioned-* fixtures-generated/version-*; do
[ -d "$d" ] && cp -r "$d" filtered-fixtures/
done
bash tests/fixtures/run-tests.sh filtered-fixtures
- name: Upload snapshot diffs
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: fixture-diffs-single
path: fixture-diffs/
retention-days: 7
if-no-files-found: ignore
fixture-config:
name: Fixtures — Config & Formats
needs: [build, fixture-generate]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: ferrflow-binary
path: bin/
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: fixtures-generated
path: fixtures-generated/
- name: Run config fixture tests
env:
FERRFLOW_BIN: ./bin/ferrflow
DIFF_DIR: fixture-diffs
run: |
chmod +x ./bin/ferrflow
mkdir -p filtered-fixtures
for d in fixtures-generated/config-* fixtures-generated/format-* fixtures-generated/multi-versioned-*; do
[ -d "$d" ] && cp -r "$d" filtered-fixtures/
done
bash tests/fixtures/run-tests.sh filtered-fixtures
- name: Upload snapshot diffs
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: fixture-diffs-config
path: fixture-diffs/
retention-days: 7
if-no-files-found: ignore
fixture-advanced:
name: Fixtures — Advanced Features
needs: [build, fixture-generate]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: ferrflow-binary
path: bin/
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: fixtures-generated
path: fixtures-generated/
- name: Run advanced fixture tests
env:
FERRFLOW_BIN: ./bin/ferrflow
DIFF_DIR: fixture-diffs
run: |
chmod +x ./bin/ferrflow
mkdir -p filtered-fixtures
for d in fixtures-generated/prerelease-* fixtures-generated/hooks-* fixtures-generated/floating-* \
fixtures-generated/tag-* fixtures-generated/changelog-* fixtures-generated/versioning-* \
fixtures-generated/orphaned-* fixtures-generated/recover-* fixtures-generated/release-* \
fixtures-generated/head-* fixtures-generated/merge-* fixtures-generated/commit-* \
fixtures-generated/skip-* fixtures-generated/no-tags-monorepo-*; do
[ -d "$d" ] && cp -r "$d" filtered-fixtures/
done
bash tests/fixtures/run-tests.sh filtered-fixtures
- name: Upload snapshot diffs
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: fixture-diffs-advanced
path: fixture-diffs/
retention-days: 7
if-no-files-found: ignore
micro-bench-build:
name: Build micro bench binary
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - name: Compile bench binary (no run)
run: cargo bench --bench ferrflow_benchmarks --no-run
- name: Locate compiled binary
id: locate
run: |
set -euo pipefail
# criterion + cargo emit a hashed binary name like
# ferrflow_benchmarks-1234abcd. Pick the most recent matching
# executable and skip dSYMs, .d files, and pdbs.
bin=$(find target/release/deps -maxdepth 1 -type f \
-name 'ferrflow_benchmarks-*' \
! -name '*.d' ! -name '*.pdb' ! -name '*.dSYM' \
-printf '%T@ %p\n' \
| sort -nr | head -n1 | cut -d' ' -f2-)
if [ -z "$bin" ] || [ ! -x "$bin" ]; then
echo "::error::could not find compiled bench binary under target/release/deps"
ls -la target/release/deps/ferrflow_benchmarks-* 2>/dev/null || true
exit 1
fi
echo "bin=$bin" >> "$GITHUB_OUTPUT"
# Strip the hash so the matrix doesn't have to re-derive it.
cp "$bin" target/release/deps/ferrflow_benchmarks
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: micro-bench-binary
path: target/release/deps/ferrflow_benchmarks
retention-days: 1
if-no-files-found: error
micro-bench:
name: Micro Benchmark - ${{ matrix.group }}
needs: micro-bench-build
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
continue-on-error: true
strategy:
fail-fast: false
matrix:
group:
- commit_parsing
- changelog
- version_files
- config_loading
- git_commits
- git_find_tag
- git_collect_tags
- git_changed_files
- git_changed_since_tag
- validate
- full_check_flow
- full_monorepo_flow
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: micro-bench-binary
path: bench-bin/
- name: Run group ${{ matrix.group }}
run: |
set +e
chmod +x bench-bin/ferrflow_benchmarks
./bench-bin/ferrflow_benchmarks --bench --output-format bencher \
"${{ matrix.group }}" \
2> bench-stderr.log | tee raw-output.txt
bench_exit=${PIPESTATUS[0]}
set -e
if [ "$bench_exit" != "0" ]; then
echo "::warning::bench binary exited ${bench_exit} — tolerating as long as rows were captured. Stderr tail:"
tail -40 bench-stderr.log || true
fi
grep -E '^test .+ \.\.\. bench:[[:space:]]+[0-9,]+ ns/iter' raw-output.txt > output.txt || true
echo "---"
echo "Filtered bencher output ($(wc -l < output.txt) lines kept):"
cat output.txt
if [ ! -s output.txt ]; then
echo "::error::no well-formed benchmark rows captured; bench exit was ${bench_exit}"
exit 1
fi
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: micro-partial-${{ matrix.group }}
path: output.txt
retention-days: 1
micro-bench-aggregate:
name: Micro Benchmark (aggregate)
needs: micro-bench
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
pattern: micro-partial-*
merge-multiple: true
path: shards/
- name: Merge partials
run: |
cat shards/* > output.txt
echo "Merged bench rows: $(wc -l < output.txt)"
- name: Find baseline artifact from main
id: find-baseline
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
RUN_ID=$(gh api "repos/${{ github.repository }}/actions/artifacts?name=criterion-baseline&per_page=1" \
--jq '.artifacts[0].workflow_run.id // empty')
echo "run_id=${RUN_ID}" >> "$GITHUB_OUTPUT"
continue-on-error: true
- name: Download baseline
if: steps.find-baseline.outputs.run_id
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: criterion-baseline
path: baseline/
run-id: ${{ steps.find-baseline.outputs.run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
continue-on-error: true
- name: Ensure baseline file
run: |
if [[ ! -f baseline/benchmark-data.json ]]; then
mkdir -p baseline
echo '[]' > baseline/benchmark-data.json
fi
- uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba with:
tool: cargo
output-file-path: output.txt
external-data-json-path: baseline/benchmark-data.json
comment-on-alert: true
alert-threshold: '120%'
fail-on-alert: false
github-token: ${{ secrets.GITHUB_TOKEN }}
comment-always: true
bench-fixtures:
name: Generate Benchmark Fixtures
needs: test
runs-on: ubuntu-latest
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Generate benchmark fixtures
uses: FerrLabs/Fixtures@1a96407b500631870c766cbe117555db19167dc9 with:
definitions: benchmarks/fixtures/definitions
generated-dir: bench-fixtures
- name: Upload benchmark fixtures
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: bench-fixtures
path: bench-fixtures/
include-hidden-files: true
retention-days: 1
if-no-files-found: error
benchmark:
name: Benchmark - ${{ matrix.fixture }}
needs: [test, build, bench-fixtures]
runs-on: ubuntu-latest
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch'
strategy:
fail-fast: false
matrix:
fixture: [single, mono-small, mono-medium, mono-100-1k, mono-50-5k, mono-large, complex]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: ferrflow-binary
path: bin/
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: bench-fixtures
path: bench-fixtures/
- name: Isolate this shard's fixture
env:
FIXTURE: ${{ matrix.fixture }}
run: |
if [[ ! -d "bench-fixtures/$FIXTURE" ]]; then
echo "::error::generated fixtures contain no '$FIXTURE'"
ls -la bench-fixtures || true
exit 1
fi
mkdir -p shard-fixtures
cp -r "bench-fixtures/$FIXTURE" shard-fixtures/
# The floor rides along in every shard, never as a shard of its own:
# startup is a property of this runner, and shards land on different
# machines, so subtracting one shard's floor from another's total
# would measure the hardware gap instead of the work.
if [[ ! -d "bench-fixtures/floor" ]]; then
echo "::error::generated fixtures contain no 'floor'"
exit 1
fi
cp -r bench-fixtures/floor shard-fixtures/
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with:
node-version: '24'
- name: Cache competitor npm globals
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with:
path: |
~/.npm
~/.cache/npx
key: bench-npm-cache-${{ hashFiles('.github/workflows/ci.yml', 'benchmarks/fixtures/definitions/*.json') }}
restore-keys: |
bench-npm-cache-
- uses: FerrLabs/Benchmarks@db1353fe328ac827ac3e1583167e43443c308655 with:
type: full
definitions: benchmarks/fixtures/definitions
ferrflow-token: ${{ secrets.GITHUB_TOKEN }}
skip-competitors: false
verbose: true
binary-dir: bin
fixtures-dir: shard-fixtures
shard: true
runs: 30
warmup: 3
- name: Upload shard results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: bench-partial-${{ matrix.fixture }}
path: benchmarks/results/latest.json
retention-days: 1
if-no-files-found: error
benchmark-aggregate:
name: Benchmark (aggregate)
needs: benchmark
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
pattern: bench-partial-*
path: partials/
- uses: FerrLabs/Benchmarks@db1353fe328ac827ac3e1583167e43443c308655 with:
type: full
definitions: benchmarks/fixtures/definitions
ferrflow-token: ${{ secrets.GITHUB_TOKEN }}
merge-partials: partials
release:
name: Release
needs: [test, fixture-monorepo, fixture-single, fixture-config, fixture-advanced, benchmark-aggregate]
runs-on: ubuntu-latest
concurrency:
group: release-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
if: |
always() &&
needs.test.result == 'success' &&
needs.fixture-monorepo.result == 'success' &&
needs.fixture-single.result == 'success' &&
needs.fixture-config.result == 'success' &&
needs.fixture-advanced.result == 'success' &&
(needs.benchmark-aggregate.result == 'success' || needs.benchmark-aggregate.result == 'skipped') &&
(
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
github.event_name == 'workflow_dispatch'
)
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
fetch-depth: 0
persist-credentials: false
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - name: Build ferrflow
run: cargo build --release
- name: Run ferrflow release
run: ./target/release/ferrflow ${{ inputs.dry_run == 'true' && '--dry-run' || '' }} release --draft
env:
FERRFLOW_BOT: "true"
- name: Download benchmark summary
if: needs.benchmark-aggregate.result == 'success'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: benchmark-release-summary
path: benchmark-summary/
continue-on-error: true
- name: Append benchmark results to draft release
if: needs.benchmark-aggregate.result == 'success'
id: append-bench
run: |
BENCH_FILE="benchmark-summary/release-summary.md"
if [[ ! -f "$BENCH_FILE" ]]; then
echo "No benchmark summary found, skipping"
exit 0
fi
TAG=$(./target/release/ferrflow tag --json 2>/dev/null | jq -r '.tag // empty')
if [[ -z "$TAG" ]]; then
echo "No tag found, skipping"
exit 0
fi
CURRENT_BODY=$(gh release view "$TAG" --json body --jq '.body' 2>/dev/null || echo "")
BENCH=$(cat "$BENCH_FILE")
# Remove existing Performance section to avoid duplicates
CLEAN_BODY=$(echo "$CURRENT_BODY" | sed '/^## Performance$/,$d')
printf -v NEW_BODY '%s\n\n%s' "$CLEAN_BODY" "$BENCH"
gh release edit "$TAG" --notes "$NEW_BODY"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}