Skip to content

chore: release v0.36.67 #1073

chore: release v0.36.67

chore: release v0.36.67 #1073

Workflow file for this run

# CI Pipeline for Grob
#
# Local testing with `act` (https://github.com/nektos/act):
# act push -j smoke # run smoke job only
# act push -j fmt # run fmt check
# act pull_request --secret-file .secrets # simulate PR (needs .secrets file)
# act -l # list all jobs
#
# Note: some jobs (container, release) require secrets not available locally.
name: CI
on:
merge_group:
push:
branches: [main]
tags:
- "v[0-9]+.[0-9]+.[0-9]+*"
paths-ignore:
- "docs/**"
- "**.md"
- "LICENSE*"
- ".editorconfig"
- ".gitignore"
- ".gitattributes"
- "**.txt"
- "CODEOWNERS"
- ".vscode/**"
pull_request:
branches: [main]
workflow_dispatch:
inputs:
run_load:
description: Run load tests
type: boolean
default: false
skip_e2e:
description: Skip E2E tests for faster iteration
type: boolean
default: false
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions: read-all
env:
CARGO_INCREMENTAL: 0
CARGO_NET_RETRY: 10
RUST_BACKTRACE: short
RUSTFLAGS: "-D warnings"
BINARY_NAME: grob
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
# ---------------------------------------------------------------------------
# Stage -1: Path-based DAG pruning (dorny/paths-filter)
# ---------------------------------------------------------------------------
jobs:
changes:
name: Detect changes
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
rust: ${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/')) && 'true' || steps.filter.outputs.rust }}
e2e: ${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/')) && 'true' || steps.filter.outputs.e2e }}
ci: ${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/')) && 'true' || steps.filter.outputs.ci }}
docs: ${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/')) && 'true' || steps.filter.outputs.docs }}
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: audit
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
rust:
- 'src/**'
- 'Cargo.toml'
- 'Cargo.lock'
e2e:
- 'tests/e2e/**'
ci:
- '.github/**'
docs:
- 'docs/**'
- '**.md'
# ---------------------------------------------------------------------------
# Stage 0: Extract version from Cargo.toml (used by later jobs)
# ---------------------------------------------------------------------------
version:
name: Extract version
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
version: ${{ steps.ver.outputs.version }}
tag: ${{ steps.ver.outputs.tag }}
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: audit
- uses: actions/checkout@v6
- name: Read version from Cargo.toml
id: ver
run: |
V=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
echo "version=${V}" >> "$GITHUB_OUTPUT"
echo "tag=v${V}" >> "$GITHUB_OUTPUT"
echo "Cargo.toml version: ${V}"
# =========================================================================
# Stage 1: Parallel lint / check gate
# =========================================================================
fmt:
name: Rustfmt
needs: [changes]
if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: audit
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --all -- --check
clippy:
name: Clippy (${{ matrix.os }})
needs: [changes]
if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.ci == 'true'
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
with:
shared-key: clippy-${{ matrix.os }}
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Run clippy
run: |
if [ "${{ matrix.os }}" = "windows-latest" ]; then
cargo clippy --all-targets --no-default-features --features dlp,oauth,tap,compliance,mcp,watch,policies,socket-opts,dirs -- -D warnings
else
cargo clippy --all-targets -- -D warnings
fi
shell: bash
audit:
name: Security Audit
needs: [changes]
if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: audit
- uses: actions/checkout@v6
- uses: rustsec/audit-check@v2.0.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
gitleaks:
name: Gitleaks
needs: [changes]
if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: audit
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install gitleaks
run: |
VERSION=8.24.3 # pinned — update manually
curl -sSfL --retry 3 --retry-delay 10 --retry-all-errors \
"https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz" \
| sudo tar xz -C /usr/local/bin gitleaks
- name: Run gitleaks
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
# PR: scan only commits in the PR against its base branch.
# Using base ref avoids broken ranges after force-pushes.
BASE="origin/${{ github.event.pull_request.base.ref }}"
echo "PR detected — scanning ${BASE}..HEAD"
gitleaks detect --source . --log-opts "${BASE}..HEAD" --verbose
else
BEFORE="${{ github.event.before }}"
if [[ "$BEFORE" == "0000000000000000000000000000000000000000" ]]; then
# Tag push or new branch — no valid range, scan last 1 commit.
echo "Tag/new-branch push detected — scanning HEAD~1..HEAD"
gitleaks detect --source . --log-opts "HEAD~1..HEAD" --verbose
else
gitleaks detect --source . --log-opts "${BEFORE}..${{ github.sha }}" --verbose
fi
fi
deny:
name: Cargo Deny
needs: [changes]
if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: audit
- uses: actions/checkout@v6
- uses: EmbarkStudios/cargo-deny-action@v2
docs:
name: Doc Coverage
needs: [changes]
if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: audit
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: docs
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Check missing doc comments
env:
RUSTDOCFLAGS: "-W missing-docs"
run: |
WARNINGS=$(cargo doc --no-deps 2>&1 | grep -c "^warning: missing documentation" || true)
if [ "$WARNINGS" -gt 0 ]; then
echo "::error::Found $WARNINGS undocumented public items"
cargo doc --no-deps 2>&1 | grep "^warning: missing documentation" -A2
exit 1
fi
echo "All public items are documented"
machete:
name: Unused Dependencies
needs: [changes]
if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: audit
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: machete
save-if: ${{ github.ref == 'refs/heads/main' }}
- uses: bnjbvr/cargo-machete@v0.9.1 # pinned — update manually
coverage:
name: Coverage
needs: [changes]
if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: audit
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- uses: Swatinem/rust-cache@v2
with:
shared-key: coverage
save-if: ${{ github.ref == 'refs/heads/main' }}
- uses: taiki-e/install-action@cargo-llvm-cov
- run: cargo llvm-cov --lcov --output-path lcov.info
- uses: codecov/codecov-action@v6
with:
files: lcov.info
fail_ci_if_error: ${{ secrets.CODECOV_TOKEN != '' }}
token: ${{ secrets.CODECOV_TOKEN }}
feature-check:
name: Feature Powerset (${{ matrix.group }}/${{ matrix.partition }})
needs: [changes]
if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
# Each shard tests its own features individually, bundles the rest.
# Partitioned 2-way for parallelism.
# core: oauth, tap, policies individual (dlp+compliance grouped)
- group: core
partition: 1
hack_args: "--group-features dlp,compliance --group-features mcp,harness,otel,acme,tls --group-features test-util,watch"
- group: core
partition: 2
hack_args: "--group-features dlp,compliance --group-features mcp,harness,otel,acme,tls --group-features test-util,watch"
# optional: mcp, harness, otel individual (acme+tls grouped)
- group: optional
partition: 1
hack_args: "--group-features dlp,oauth,tap,compliance,policies --group-features acme,tls --group-features test-util,watch"
- group: optional
partition: 2
hack_args: "--group-features dlp,oauth,tap,compliance,policies --group-features acme,tls --group-features test-util,watch"
# minimal: test-util, watch individual, everything else in 2 bundles
- group: minimal
partition: 1
hack_args: "--group-features dlp,oauth,tap,compliance,policies --group-features mcp,harness,otel,acme,tls"
- group: minimal
partition: 2
hack_args: "--group-features dlp,oauth,tap,compliance,policies --group-features mcp,harness,otel,acme,tls"
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: audit
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: hack-${{ matrix.group }}
save-if: ${{ github.ref == 'refs/heads/main' }}
- uses: taiki-e/install-action@cargo-hack
- run: cargo hack check --feature-powerset --depth 2 --no-dev-deps --partition ${{ matrix.partition }}/2 ${{ matrix.hack_args }}
# =========================================================================
# Stage 2: Tests (gate for everything below)
# =========================================================================
test-ubuntu:
name: Test Ubuntu (shard ${{ matrix.shard }}/4)
needs:
- changes
- fmt
- clippy
- deny
if: |
always() &&
(needs.changes.outputs.rust == 'true' || needs.changes.outputs.ci == 'true') &&
!contains(needs.*.result, 'failure') &&
!contains(needs.*.result, 'cancelled')
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: audit
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: test-ubuntu-latest
save-if: ${{ github.ref == 'refs/heads/main' && matrix.shard == 1 }}
- uses: taiki-e/install-action@nextest
- run: cargo nextest run --profile ci --partition count:${{ matrix.shard }}/4
- run: cargo test --doc
if: matrix.shard == 1
# Gate job so the branch-protection rule "Test (ubuntu-latest)" is satisfied
# by the sharded test-ubuntu matrix.
test-ubuntu-gate:
name: Test (ubuntu-latest)
if: always()
needs: [test-ubuntu]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check shard results
run: |
# Only a genuine "failure" blocks; a "cancelled" shard (concurrency
# auto-cancel / preemption) is non-fatal, mirroring the Required gate.
if [[ "${{ needs.test-ubuntu.result }}" == "failure" ]]; then
echo "::error::Ubuntu test shards failed"
exit 1
fi
echo "All Ubuntu test shards passed (or were cancelled benignly)."
test-other:
name: Test (${{ matrix.os }})
needs:
- changes
- fmt
- clippy
- deny
if: |
always() &&
(needs.changes.outputs.rust == 'true' || needs.changes.outputs.ci == 'true') &&
!contains(needs.*.result, 'failure') &&
!contains(needs.*.result, 'cancelled')
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest]
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: test-${{ matrix.os }}
save-if: ${{ github.ref == 'refs/heads/main' }}
- uses: taiki-e/install-action@nextest
- name: Run tests
run: |
if [ "${{ matrix.os }}" = "windows-latest" ]; then
cargo nextest run --profile ci --no-default-features --features dlp,oauth,tap,compliance,mcp,watch,policies,socket-opts,dirs
cargo test --doc --no-default-features --features dlp,oauth,tap,compliance,mcp,watch,policies,socket-opts,dirs
else
cargo nextest run --profile ci
cargo test --doc
fi
shell: bash
# =========================================================================
# Stage 2.5: Mutation testing (main push only, informational, full matrix)
# Sharded: ~1 file per job to keep wall-clock per shard under 60 min.
# Scope expanded 2026-04-26: + src/server/dispatch/ (3 shards),
# + src/routing/classify/ (2 shards). See docs/explanation/.
# =========================================================================
mutants:
name: Mutation Testing (shard ${{ matrix.shard }})
if: github.ref == 'refs/heads/main'
needs: [test-ubuntu, test-other]
runs-on: ubuntu-latest
timeout-minutes: 60
continue-on-error: true
strategy:
fail-fast: false
matrix:
include:
- shard: "1"
file: src/router/mod.rs
mutants_shard: ""
- shard: "2"
file: src/features/dlp/mod.rs
mutants_shard: ""
# pii.rs exceeds 60min budget on a single job — native split via
# cargo-mutants `--shard K/N`, no production code change.
- shard: "3a"
file: src/features/dlp/pii.rs
mutants_shard: "0/2"
- shard: "3b"
file: src/features/dlp/pii.rs
mutants_shard: "1/2"
- shard: "4"
file: src/features/dlp/dfa.rs
mutants_shard: ""
# Dispatch pipeline (T-CI-0e, 2026-04-26): mod.rs (575 LoC) and
# retry.rs (478 LoC) are the largest; split into 3 shards. The
# provider_loop.rs / resolver.rs / telemetry.rs siblings are
# bundled into shard 5c via two --file passes.
- shard: "5a"
file: src/server/dispatch/mod.rs
mutants_shard: "0/2"
- shard: "5b"
file: src/server/dispatch/mod.rs
mutants_shard: "1/2"
- shard: "5c"
file: src/server/dispatch/retry.rs
mutants_shard: ""
- shard: "5d"
file: src/server/dispatch/provider_loop.rs
mutants_shard: ""
# Classify engine (T-CI-0e, 2026-04-26): mod.rs (476 LoC) +
# classify.rs (512 LoC) split into 2 shards each by file.
- shard: "6a"
file: src/routing/classify/mod.rs
mutants_shard: ""
- shard: "6b"
file: src/routing/classify/classify.rs
mutants_shard: ""
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: audit
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: mutants-${{ matrix.shard }}
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install cargo-mutants
run: cargo install cargo-mutants@24.11.2 --locked # pinned — update manually
- name: Run mutation testing on ${{ matrix.file }}
run: |
SHARD_ARGS=()
if [ -n "${{ matrix.mutants_shard }}" ]; then
SHARD_ARGS+=(--shard "${{ matrix.mutants_shard }}")
fi
cargo mutants --package grob --timeout 120 -j 2 \
"${SHARD_ARGS[@]}" \
--file ${{ matrix.file }} \
-- --lib
- name: Upload mutation testing results
if: always()
uses: actions/upload-artifact@v7
with:
name: mutants-results-shard-${{ matrix.shard }}-${{ github.sha }}
path: mutants.out/
if-no-files-found: ignore
# =========================================================================
# Stage 2.6: Mutation testing on PRs (diff-based sampling, 25 min cap)
# Runs only on the files the PR touches, restricted to the curated
# mutation-tested scope (router, dispatch, classify, dlp). Skips when
# no Rust files in scope changed. Always informational — never blocks
# the merge. The full matrix on `main` remains the source of truth.
# =========================================================================
mutants-pr:
name: Mutation Testing (PR diff)
if: github.event_name == 'pull_request'
needs: [test-ubuntu]
runs-on: ubuntu-latest
timeout-minutes: 30
continue-on-error: true
permissions:
contents: read
pull-requests: write # post sticky comment with sampling summary
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: audit
- uses: actions/checkout@v6
with:
# Need the merge-base to compute `BASE...HEAD` diff.
fetch-depth: 0
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: mutants-pr
save-if: false # never write the cache from PR runs
- name: Install cargo-mutants
run: cargo install cargo-mutants@24.11.2 --locked # pinned — update manually
- name: Run mutation testing on changed files
id: mutate
env:
MUTATION_TIMEOUT_SECONDS: "1500"
MUTATION_PER_MUTANT_TIMEOUT: "120"
run: |
set +e
./scripts/mutation-pr.sh "origin/${{ github.event.pull_request.base.ref }}"
rc=$?
set -e
# Exit codes: 0 clean, 1 missed mutants, 2 timed out, 3 nothing in scope.
# All are informational — never fail the job from this step.
echo "Mutation script exit: ${rc}"
exit 0
- name: Upload mutation testing results
if: always()
uses: actions/upload-artifact@v7
with:
name: mutants-pr-results-${{ github.sha }}
path: mutants.out/
if-no-files-found: ignore
- name: Write PR sampling summary to job summary
if: always()
env:
STATUS: ${{ steps.mutate.outputs.status || 'unknown' }}
DURATION: ${{ steps.mutate.outputs.duration_s || 'n/a' }}
TOTAL: ${{ steps.mutate.outputs.total || 'n/a' }}
CAUGHT: ${{ steps.mutate.outputs.caught || 'n/a' }}
MISSED: ${{ steps.mutate.outputs.missed || 'n/a' }}
TIMEOUT_N: ${{ steps.mutate.outputs.timeout || 'n/a' }}
UNVIABLE: ${{ steps.mutate.outputs.unviable || 'n/a' }}
ARTIFACT: mutants-pr-results-${{ github.sha }}
run: |
# Plain ASCII output — backticks elided to dodge shellcheck SC2016
# noise inside the actionlint pipeline.
{
echo "## Mutation testing (PR diff sample)"
echo
echo "Informational only — never blocks merge. Full matrix runs on main."
echo
echo "| Metric | Value |"
echo "|--------|-------|"
echo "| Status | ${STATUS} |"
echo "| Duration | ${DURATION} s |"
echo "| Total mutants | ${TOTAL} |"
echo "| Caught | ${CAUGHT} |"
echo "| Missed | ${MISSED} |"
echo "| Timeout | ${TIMEOUT_N} |"
echo "| Unviable | ${UNVIABLE} |"
echo
echo "Status legend: clean (no survivors), missed (inspect artifact),"
echo "timed-out (25 min cap reached), skipped-* (no in-scope diff)."
echo
echo "Artifact: ${ARTIFACT}."
} >>"${GITHUB_STEP_SUMMARY}"
- name: Comment PR with sampling summary
# Skip on forks (no token write access) and when nothing was sampled.
if: always() && github.event.pull_request.head.repo.full_name == github.repository && steps.mutate.outputs.status != 'skipped-no-rust' && steps.mutate.outputs.status != 'skipped-out-of-scope'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
STATUS: ${{ steps.mutate.outputs.status || 'unknown' }}
DURATION: ${{ steps.mutate.outputs.duration_s || 'n/a' }}
TOTAL: ${{ steps.mutate.outputs.total || 'n/a' }}
CAUGHT: ${{ steps.mutate.outputs.caught || 'n/a' }}
MISSED: ${{ steps.mutate.outputs.missed || 'n/a' }}
TIMEOUT_N: ${{ steps.mutate.outputs.timeout || 'n/a' }}
UNVIABLE: ${{ steps.mutate.outputs.unviable || 'n/a' }}
ARTIFACT: mutants-pr-results-${{ github.sha }}
run: |
# Build the comment body via heredoc-style echos (no single-quoted
# printf format; shellcheck SC2016-friendly). The marker line is
# the discriminator we grep for to upsert prior comments.
MARKER="<!-- mutants-pr-summary -->"
BODY_FILE="$(mktemp)"
{
echo "${MARKER}"
echo "## Mutation testing (PR diff sample)"
echo
echo "Informational — never blocks merge. Full matrix runs on main."
echo
echo "| Metric | Value |"
echo "|--------|-------|"
echo "| Status | ${STATUS} |"
echo "| Duration | ${DURATION} s |"
echo "| Total | ${TOTAL} |"
echo "| Caught | ${CAUGHT} |"
echo "| Missed | ${MISSED} |"
echo "| Timeout | ${TIMEOUT_N} |"
echo "| Unviable | ${UNVIABLE} |"
echo
echo "Legend: clean (no survivors), missed (inspect artifact), timed-out (25 min cap reached)."
echo
echo "Artifact: ${ARTIFACT}."
} >"${BODY_FILE}"
# Look for an existing summary comment to update; otherwise post a
# new one. `gh issue comment` does not support upsert natively, so
# we list comments, filter by marker, and PATCH if found.
COMMENTS_URL="repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments"
EXISTING_ID=$(gh api "${COMMENTS_URL}" --jq "[.[] | select(.body | startswith(\"${MARKER}\"))][0].id" || true)
if [ -n "${EXISTING_ID}" ] && [ "${EXISTING_ID}" != "null" ]; then
# PATCH a comment by ID — body comes from the file via jq -Rs,
# which slurps the whole file as a single JSON string for the
# request body envelope.
jq -Rs '{body: .}' "${BODY_FILE}" \
| gh api --method PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${EXISTING_ID}" --input - >/dev/null
echo "Updated existing comment ${EXISTING_ID}."
else
gh pr comment "${PR_NUMBER}" --body-file "${BODY_FILE}"
fi
rm -f "${BODY_FILE}"
# =========================================================================
# Stage 3: Cross build (main push + tag push only, not PRs)
# =========================================================================
build:
name: Build ${{ matrix.target }}
if: github.event_name == 'push'
needs: [changes, version, test-ubuntu, test-other]
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
use-cross: true
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
use-cross: true
- os: macos-latest
target: x86_64-apple-darwin
use-cross: false
- os: macos-latest
target: aarch64-apple-darwin
use-cross: false
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: audit
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install cross
if: matrix.use-cross
run: |
VERSION=0.2.5 # pinned — update manually
curl -fsSL --retry 3 --retry-delay 10 --retry-all-errors \
"https://github.com/cross-rs/cross/releases/download/v${VERSION}/cross-x86_64-unknown-linux-musl.tar.gz" \
| tar xz -C /usr/local/bin
cross --version
- name: Build
shell: bash
run: |
if [[ "${{ matrix.use-cross }}" == "true" ]]; then
cross build --locked --release --target ${{ matrix.target }}
else
cargo build --locked --release --target ${{ matrix.target }}
fi
- name: Package
run: |
VERSION="v${{ needs.version.outputs.version }}"
ARCHIVE="${BINARY_NAME}-${VERSION}-${{ matrix.target }}.tar.gz"
tar czf "${ARCHIVE}" \
-C "target/${{ matrix.target }}/release" "${BINARY_NAME}" \
-C "${GITHUB_WORKSPACE}" README.md LICENSE
shasum -a 256 "${ARCHIVE}" > "${ARCHIVE}.sha256"
echo "ARCHIVE=${ARCHIVE}" >> "$GITHUB_ENV"
- uses: actions/upload-artifact@v7
with:
name: ${{ matrix.target }}-${{ github.sha }}
path: |
${{ env.ARCHIVE }}
${{ env.ARCHIVE }}.sha256
if-no-files-found: error
compression-level: 9
# =========================================================================
# Stage 4: Push GHCR multi-arch image (main push + tag push only)
# =========================================================================
container:
name: Container image
if: github.event_name == 'push'
needs: [version, build]
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
packages: write
id-token: write # cosign keyless signing
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: audit
- uses: actions/download-artifact@v8
with:
name: x86_64-unknown-linux-musl-${{ github.sha }}
path: artifacts/amd64
- uses: actions/download-artifact@v8
with:
name: aarch64-unknown-linux-musl-${{ github.sha }}
path: artifacts/arm64
- name: Extract binaries
run: |
VERSION="v${{ needs.version.outputs.version }}"
mkdir -p bin/amd64 bin/arm64
tar xzf "artifacts/amd64/${BINARY_NAME}-${VERSION}-x86_64-unknown-linux-musl.tar.gz" -C bin/amd64 "${BINARY_NAME}"
tar xzf "artifacts/arm64/${BINARY_NAME}-${VERSION}-aarch64-unknown-linux-musl.tar.gz" -C bin/arm64 "${BINARY_NAME}"
chmod +x "bin/amd64/${BINARY_NAME}" "bin/arm64/${BINARY_NAME}"
- name: Install crane
run: |
VERSION=0.21.2
curl -fsSL --retry 3 --retry-delay 10 --retry-all-errors \
"https://github.com/google/go-containerregistry/releases/download/v${VERSION}/go-containerregistry_Linux_x86_64.tar.gz" \
| tar xz -C /usr/local/bin crane
- name: Login to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | crane auth login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
- name: Build and push multi-arch image
run: |
VERSION="v${{ needs.version.outputs.version }}"
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
# Build per-arch images (static musl binary, no OS needed)
for ARCH in amd64 arm64; do
mkdir -p "image-root-${ARCH}/usr/local/bin"
cp "bin/${ARCH}/${BINARY_NAME}" "image-root-${ARCH}/usr/local/bin/${BINARY_NAME}"
tar -cf "layer-${ARCH}.tar" -C "image-root-${ARCH}" .
crane append -t "${IMAGE}:${VERSION#v}-${ARCH}" -f "layer-${ARCH}.tar"
# Set platform metadata and OCI annotations
crane mutate "${IMAGE}:${VERSION#v}-${ARCH}" \
--set-platform "linux/${ARCH}" \
--entrypoint "/usr/local/bin/${BINARY_NAME}" \
--cmd run \
--annotation org.opencontainers.image.description="Multi-provider LLM routing proxy with automatic fallback" \
--annotation org.opencontainers.image.source="https://github.com/${{ github.repository }}" \
--annotation org.opencontainers.image.version="${VERSION#v}" \
--annotation org.opencontainers.image.licenses="AGPL-3.0-only"
done
# Create multi-arch manifest list
crane index append \
-t "${IMAGE}:${VERSION#v}" \
-m "${IMAGE}:${VERSION#v}-amd64" \
-m "${IMAGE}:${VERSION#v}-arm64"
# Tag additional versions
MAJOR_MINOR="$(echo "${VERSION#v}" | cut -d. -f1-2)"
crane tag "${IMAGE}:${VERSION#v}" "${MAJOR_MINOR}"
if [[ ! "$VERSION" =~ - ]]; then
crane tag "${IMAGE}:${VERSION#v}" latest
fi
- name: Generate SBOM
uses: anchore/sbom-action@v0
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.version.outputs.version }}
artifact-name: sbom-grob.spdx.json
output-file: sbom-grob.spdx.json
- name: Install cosign
uses: sigstore/cosign-installer@v4.1.1
- name: Sign container image
run: |
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
VERSION="${{ needs.version.outputs.version }}"
DIGEST=$(crane digest "${IMAGE}:${VERSION}")
cosign sign --yes "${IMAGE}@${DIGEST}"
- uses: actions/upload-artifact@v7
with:
name: sbom-${{ github.sha }}
path: sbom-grob.spdx.json
compression-level: 9
# =========================================================================
# Stage 5: Update Homebrew tap (tag push only — needs release assets)
# =========================================================================
homebrew:
name: Update Homebrew tap
if: startsWith(github.ref, 'refs/tags/v')
needs: [version, release]
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Update formula
env:
GH_TOKEN: ${{ secrets.RELEASE_PLZ_TOKEN }}
run: |
VERSION="v${{ needs.version.outputs.version }}"
V="${VERSION#v}"
# Download and hash all 4 tarballs from the GitHub Release.
# This job runs only on tag pushes and depends on the release job,
# so assets are guaranteed to exist at this point.
for TARGET in aarch64-apple-darwin x86_64-apple-darwin aarch64-unknown-linux-musl x86_64-unknown-linux-musl; do
URL="https://github.com/azerozero/grob/releases/download/${VERSION}/grob-${VERSION}-${TARGET}.tar.gz"
SHA=$(curl -sSL "$URL" | sha256sum | cut -d' ' -f1)
eval "SHA_${TARGET//-/_}=$SHA"
done
# Generate formula
cat > /tmp/grob.rb << RUBY
class Grob < Formula
desc "High-performance LLM routing proxy with built-in DLP"
homepage "https://github.com/azerozero/grob"
version "${V}"
license "AGPL-3.0-only"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/azerozero/grob/releases/download/v#{version}/grob-v#{version}-aarch64-apple-darwin.tar.gz"
sha256 "${SHA_aarch64_apple_darwin}"
else
url "https://github.com/azerozero/grob/releases/download/v#{version}/grob-v#{version}-x86_64-apple-darwin.tar.gz"
sha256 "${SHA_x86_64_apple_darwin}"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/azerozero/grob/releases/download/v#{version}/grob-v#{version}-aarch64-unknown-linux-musl.tar.gz"
sha256 "${SHA_aarch64_unknown_linux_musl}"
else
url "https://github.com/azerozero/grob/releases/download/v#{version}/grob-v#{version}-x86_64-unknown-linux-musl.tar.gz"
sha256 "${SHA_x86_64_unknown_linux_musl}"
end
end
def install
bin.install "grob"
end
test do
assert_match "grob #{version}", shell_output("#{bin}/grob --version")
end
end
RUBY
# Remove leading whitespace from heredoc
sed -i 's/^ //' /tmp/grob.rb
# Clone tap, update formula, push
git clone "https://x-access-token:${GH_TOKEN}@github.com/azerozero/homebrew-tap.git" /tmp/tap
cp /tmp/grob.rb /tmp/tap/Formula/grob.rb
cd /tmp/tap
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Formula/grob.rb
git commit -m "grob ${V}"
git push
# =========================================================================
# Stage 6: E2E tests (main push + tag push only)
# Pulls the SPECIFIC version image just pushed by container job.
# =========================================================================
e2e:
name: E2E tests
if: |
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && !inputs.skip_e2e)) &&
(needs.changes.outputs.e2e == 'true' || needs.changes.outputs.rust == 'true')
needs: [changes, version, container, test-ubuntu]
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: audit
- uses: actions/checkout@v6
- name: Setup E2E pod
uses: ./.github/actions/setup-e2e-pod
with:
version: ${{ needs.version.outputs.version }}
registry: ${{ env.REGISTRY }}
image_name: ${{ env.IMAGE_NAME }}
# NOTE: no retry — flaky E2E must be fixed at the source, not masked.
# Audit 2026-04-18 flagged retry-masking as structural Tier 2 smell.
- name: Run hurl tests
timeout-minutes: 10
run: cd tests/e2e && make test-hurl
- name: Collect logs
if: failure()
run: podman pod exists e2e-pod && podman logs e2e-pod-grob 2>&1 | tail -100 || true
- name: Teardown
if: always()
working-directory: tests/e2e
run: make down
# =========================================================================
# Stage 7: Load test (manual trigger only)
# =========================================================================
load:
name: Load test (smoke)
if: github.event_name == 'workflow_dispatch' && inputs.run_load
needs: [version, e2e]
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: audit
- uses: actions/checkout@v6
- name: Setup E2E pod
uses: ./.github/actions/setup-e2e-pod
with:
version: ${{ needs.version.outputs.version }}
registry: ${{ env.REGISTRY }}
image_name: ${{ env.IMAGE_NAME }}
# NOTE: no retry — see hurl block above for rationale (audit 2026-04-18).
- name: Run k6 smoke load test
timeout-minutes: 15
run: cd tests/e2e && make load
- name: Teardown
if: always()
working-directory: tests/e2e
run: make down
# =========================================================================
# Stage 8: GitHub Release (tag push only, after e2e passes)
# =========================================================================
release:
name: Create Release
if: startsWith(github.ref, 'refs/tags/v')
needs: [version, build, container, e2e]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write # create GitHub Release
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: audit
- uses: actions/checkout@v6
- uses: actions/download-artifact@v8
with:
path: artifacts
merge-multiple: true
- name: Checksums
run: |
cd artifacts
{
echo "## SHA256 Checksums"
echo '```'
cat ./*.sha256
echo '```'
} > ../CHECKSUMS.md
- name: Validate tag matches Cargo.toml
run: |
TAG_VERSION="${GITHUB_REF#refs/tags/}"
CARGO_VERSION="v${{ needs.version.outputs.version }}"
if [[ "$TAG_VERSION" != "$CARGO_VERSION" && ! "$TAG_VERSION" =~ ^${CARGO_VERSION}- ]]; then
echo "::error::Tag $TAG_VERSION does not match Cargo.toml version $CARGO_VERSION"
exit 1
fi
- name: Create release and upload assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="v${{ needs.version.outputs.version }}"
PRERELEASE=""
if [[ "$VERSION" == *-* ]]; then
PRERELEASE="--prerelease"
fi
# Release workflow is the sole creator of GitHub Releases.
# release-plz only creates the tag (git_release_enable = false).
gh release create "$VERSION" \
--repo "$GITHUB_REPOSITORY" \
--title "$VERSION" \
--generate-notes \
--notes-file CHECKSUMS.md \
$PRERELEASE \
artifacts/*.tar.gz artifacts/*.sha256
homebrew-test:
name: Test Homebrew install
if: startsWith(github.ref, 'refs/tags/v')
needs: [version, homebrew]
runs-on: macos-latest
timeout-minutes: 10
steps:
- name: Install via brew
run: |
brew install azerozero/tap/grob
grob --version
INSTALLED=$(grob --version | awk '{print $2}')
EXPECTED="${{ needs.version.outputs.version }}"
if [[ "$INSTALLED" != "$EXPECTED" ]]; then
echo "::error::Version mismatch: installed=$INSTALLED expected=$EXPECTED"
exit 1
fi
- name: Test brew upgrade (reinstall simulates upgrade)
run: |
brew reinstall azerozero/tap/grob
grob --version
# =========================================================================
# Stage 9.1: Validate YAML (DX — catches syntax errors early)
# =========================================================================
validate-yaml:
name: Validate CI YAML
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: audit
- uses: actions/checkout@v6
- uses: rhysd/actionlint@v1.7.11
with:
args: -color
# =========================================================================
# Stage 9.2: Required — merge queue gate (aggregates all required checks)
# =========================================================================
required:
name: Required checks
if: always()
needs:
- changes
- fmt
- clippy
- audit
- gitleaks
- deny
- docs
- machete
- coverage
- feature-check
- test-ubuntu
- test-other
- validate-yaml
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check required jobs
run: |
# This is the ONLY job referenced in the GitHub ruleset "Protect main".
# Do NOT add individual job names (Clippy, Rustfmt, etc.) to the ruleset —
# path pruning skips them on non-Rust changes, causing eternal "pending".
# See: https://github.com/azerozero/grob/pull/153
#
# Fail ONLY when a required job genuinely failed. "skipped" (path
# pruning) and "cancelled" (concurrency auto-cancel, runner
# preemption) are both non-fatal — a benign cancellation must never
# block a release. A real regression still surfaces as "failure".
results=( \
"${{ needs.fmt.result }}" \
"${{ needs.clippy.result }}" \
"${{ needs.audit.result }}" \
"${{ needs.gitleaks.result }}" \
"${{ needs.deny.result }}" \
"${{ needs.docs.result }}" \
"${{ needs.machete.result }}" \
"${{ needs.coverage.result }}" \
"${{ needs.feature-check.result }}" \
"${{ needs.test-ubuntu.result }}" \
"${{ needs.test-other.result }}" \
"${{ needs.validate-yaml.result }}" \
)
for r in "${results[@]}"; do
if [[ "$r" == "failure" ]]; then
echo "::error::Required job failed: $r"
exit 1
fi
done
echo "All required checks passed (skipped/cancelled jobs are non-fatal)."
# =========================================================================
# Stage 10: Pipeline Summary
# =========================================================================
summary:
name: Pipeline Summary
if: always()
permissions:
checks: write # job summary
needs:
- changes
- fmt
- clippy
- audit
- gitleaks
- deny
- docs
- machete
- coverage
- feature-check
- test-ubuntu
- test-other
# mutants retire du needs (T-CI-0d): le job a deja continue-on-error: true,
# le garder ici ne faisait que propager ses cancellations dans le statut
# global de la pipeline sans ajouter de signal utile.
- build
- container
- e2e
- load
- release
- homebrew
- homebrew-test
- validate-yaml
- required
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Collect job results and durations
uses: actions/github-script@v8
with:
script: |
const jobs = [
'changes', 'fmt', 'clippy', 'audit', 'gitleaks', 'deny',
'docs', 'machete', 'coverage', 'feature-check',
'test-ubuntu', 'test-other', 'build', 'container',
'e2e', 'load', 'release', 'homebrew', 'homebrew-test',
'validate-yaml', 'required'
];
const needs = ${{ toJSON(needs) }};
const pipelineStart = new Date('${{ github.event.head_commit.timestamp || github.event.pull_request.updated_at || github.event.repository.pushed_at }}');
const now = new Date();
const totalMin = ((now - pipelineStart) / 60000).toFixed(1);
let md = `## Pipeline Summary\n\n`;
md += `**Total duration:** ~${totalMin} min | **Ref:** \`${{ github.ref_name }}\` | **SHA:** \`${{ github.sha }}\`\n\n`;
md += `| Job | Result |\n|-----|--------|\n`;
for (const job of jobs) {
const result = needs[job]?.result || 'n/a';
const icon = {
success: ':white_check_mark:',
failure: ':x:',
cancelled: ':no_entry_sign:',
skipped: ':fast_forward:'
}[result] || ':grey_question:';
md += `| ${job} | ${icon} ${result} |\n`;
}
md += `\n### Cache\n\n`;
md += `> Cache hit/miss details are available in individual job logs `;
md += `(look for \`rust-cache\` post-action output).\n`;
md += `\n### Test Results\n\n`;
md += `> Test counts are available in the \`test-ubuntu\` and \`test-other\` job logs `;
md += `(nextest summary output).\n`;
core.summary.addRaw(md);
await core.summary.write();
- name: Export pipeline duration for trend tracking (D9)
uses: actions/github-script@v8
with:
script: |
const fs = require('fs');
const pipelineStart = new Date('${{ github.event.head_commit.timestamp || github.event.pull_request.updated_at || github.event.repository.pushed_at }}');
const now = new Date();
const data = {
sha: '${{ github.sha }}',
ref: '${{ github.ref_name }}',
event: '${{ github.event_name }}',
run_id: '${{ github.run_id }}',
duration_sec: Math.round((now - pipelineStart) / 1000),
timestamp: now.toISOString(),
jobs: {}
};
const needs = ${{ toJSON(needs) }};
for (const [k, v] of Object.entries(needs)) {
data.jobs[k] = v.result;
}
fs.writeFileSync('/tmp/ci-benchmark.json', JSON.stringify(data, null, 2));
- uses: actions/upload-artifact@v7
with:
name: ci-benchmark-${{ github.sha }}
path: /tmp/ci-benchmark.json
retention-days: 90
compression-level: 9