Skip to content

refactor: apply cargo qual code quality rules #761

refactor: apply cargo qual code quality rules

refactor: apply cargo qual code quality rules #761

Workflow file for this run

# SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
#
# SPDX-License-Identifier: MIT
name: CI
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
workflow_dispatch:
inputs:
force_publish:
description: "Force publish all crates (ignores version check)"
type: boolean
default: false
skip_tests:
description: "Skip test stage (for emergency releases)"
type: boolean
default: false
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: write
pull-requests: write
id-token: write
attestations: write
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: short
RUSTFLAGS: -D warnings
# Crate publish order (dependencies first)
CRATES: "masterror-template masterror-derive masterror"
jobs:
# ════════════════════════════════════════════════════════════════════════════
# Read MSRV from Cargo.toml (single source of truth)
# ════════════════════════════════════════════════════════════════════════════
msrv:
name: Read MSRV
runs-on: ubuntu-latest
outputs:
version: ${{ steps.msrv.outputs.version }}
steps:
- uses: actions/checkout@v5
- name: Extract MSRV from Cargo.toml
id: msrv
run: |
MSRV=$(grep '^rust-version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
echo "version=$MSRV" >> "$GITHUB_OUTPUT"
echo "MSRV: $MSRV"
# ════════════════════════════════════════════════════════════════════════════
# STAGE 1: CHECKS (parallel matrix)
# ════════════════════════════════════════════════════════════════════════════
check:
name: Check (${{ matrix.rust }} / ${{ matrix.os }})
needs: msrv
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
# MSRV (from Cargo.toml)
- rust: msrv
os: ubuntu-latest
msrv: true
# Stable (primary)
- rust: stable
os: ubuntu-latest
# Stable on macOS
- rust: stable
os: macos-latest
# Stable on Windows
- rust: stable
os: windows-latest
# Nightly (for future compat)
- rust: nightly
os: ubuntu-latest
allow_fail: true
continue-on-error: ${{ matrix.allow_fail || false }}
steps:
- uses: actions/checkout@v5
- name: Resolve toolchain
id: toolchain
shell: bash
run: |
if [ "${{ matrix.rust }}" = "msrv" ]; then
echo "version=${{ needs.msrv.outputs.version }}" >> "$GITHUB_OUTPUT"
else
echo "version=${{ matrix.rust }}" >> "$GITHUB_OUTPUT"
fi
- name: Install Rust ${{ steps.toolchain.outputs.version }}
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ steps.toolchain.outputs.version }}
components: clippy
- name: Cache
uses: Swatinem/rust-cache@v2
with:
shared-key: ${{ steps.toolchain.outputs.version }}-${{ matrix.os }}
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Clippy
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
- name: Check (all features)
run: cargo check --workspace --all-features
- name: Check (no default features)
run: cargo check --workspace --no-default-features
fmt:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust nightly
uses: dtolnay/rust-toolchain@nightly
with:
components: rustfmt
- name: Check formatting
run: cargo +nightly fmt --all -- --check
docs:
name: Documentation
runs-on: ubuntu-latest
env:
RUSTDOCFLAGS: -D warnings
steps:
- uses: actions/checkout@v5
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Cache
uses: Swatinem/rust-cache@v2
- name: Build docs
run: cargo doc --workspace --all-features --no-deps
no-std:
name: no_std (${{ matrix.name }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- name: bare
args: --no-default-features
- name: std-only
args: --features std
- name: tracing
args: --no-default-features --features tracing
- name: metrics
args: --no-default-features --features metrics
- name: colored
args: --no-default-features --features colored
- name: all-features
args: --all-features
steps:
- uses: actions/checkout@v5
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Cache
uses: Swatinem/rust-cache@v2
with:
shared-key: no-std-${{ matrix.name }}
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Check ${{ matrix.name }}
run: cargo check ${{ matrix.args }}
security:
name: Security Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Install tools
uses: taiki-e/install-action@v2
with:
tool: cargo-deny,cargo-audit
- name: Cargo deny
run: cargo deny check
- name: Cargo audit
run: cargo audit
reuse:
name: REUSE Compliance
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install reuse
run: pip install --user reuse
- name: Check REUSE compliance
run: reuse lint
# ════════════════════════════════════════════════════════════════════════════
# STAGE 2: TEST (after checks pass)
# ════════════════════════════════════════════════════════════════════════════
test:
name: Test Suite
needs: [check, fmt, no-std, security, reuse]
if: ${{ !inputs.skip_tests }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Cache
uses: Swatinem/rust-cache@v2
with:
shared-key: test
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install nextest
uses: taiki-e/install-action@v2
with:
tool: cargo-nextest
- name: Run tests
run: cargo nextest run --workspace --all-features --profile ci
- name: Run doctests
run: cargo test --doc --workspace --all-features
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: target/nextest/ci/junit.xml
if-no-files-found: ignore
retention-days: 30
coverage:
name: Coverage
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- name: Cache
uses: Swatinem/rust-cache@v2
with:
shared-key: coverage
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install tools
uses: taiki-e/install-action@v2
with:
tool: cargo-llvm-cov,cargo-nextest
- name: Generate coverage
run: |
cargo llvm-cov nextest --workspace --all-features --profile ci --lcov --output-path lcov.info
cargo llvm-cov report --html
- name: Upload to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: lcov.info
fail_ci_if_error: false
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: target/llvm-cov/html/
retention-days: 30
- name: Coverage summary
run: |
COVERAGE=$(cargo llvm-cov report 2>/dev/null | grep TOTAL | awk '{print $NF}' || echo "N/A")
echo "## Coverage: $COVERAGE" >> $GITHUB_STEP_SUMMARY
benchmarks:
name: Benchmarks
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v5
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Cache
uses: Swatinem/rust-cache@v2
with:
shared-key: bench
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Run benchmarks
run: cargo bench --features benchmarks -- --save-baseline ci
- name: Upload results
uses: actions/upload-artifact@v4
with:
name: benchmark-results
path: target/criterion/
retention-days: 30
# ════════════════════════════════════════════════════════════════════════════
# STAGE 3: RELEASE (after tests pass, main branch only)
# ════════════════════════════════════════════════════════════════════════════
changelog:
name: Update Changelog
needs: [check, fmt, no-std, security, reuse]
runs-on: ubuntu-latest
if: |
(github.event_name == 'push' || github.event_name == 'workflow_dispatch') &&
github.ref == 'refs/heads/main' &&
!contains(github.event.head_commit.message || '', '[skip ci]')
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
token: ${{ secrets.GH_TOKEN }}
- name: Install git-cliff
uses: taiki-e/install-action@v2
with:
tool: git-cliff
- name: Generate changelog
id: changelog
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# Try with GitHub API first (for contributors)
if ! git cliff --config cliff.toml --github-token "$GITHUB_TOKEN" -o CHANGELOG.md 2>/dev/null; then
echo "::warning::GitHub API unavailable, generating without contributors"
git cliff --config cliff.toml -o CHANGELOG.md
fi
if git diff --quiet CHANGELOG.md 2>/dev/null; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Commit changelog
if: steps.changelog.outputs.changed == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Retry push with conflict resolution
for i in 1 2 3; do
git add CHANGELOG.md
git diff --cached --quiet && { echo "No changes to commit"; exit 0; }
git commit -m "chore: update CHANGELOG.md [skip ci]" || true
if git push origin main 2>&1; then
echo "Push successful"
exit 0
fi
echo "Push failed, fetching and regenerating..."
git fetch origin main
git reset --hard origin/main
# Regenerate changelog on latest main
if ! git cliff --config cliff.toml --github-token "$GITHUB_TOKEN" -o CHANGELOG.md 2>/dev/null; then
git cliff --config cliff.toml -o CHANGELOG.md
fi
sleep $((i * 2))
done
echo "::warning::Failed to push changelog after 3 attempts, skipping"
exit 0
release:
name: Release
needs: [test, changelog]
if: |
always() &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(github.event_name == 'push' || github.event_name == 'workflow_dispatch') &&
github.ref == 'refs/heads/main' &&
!contains(github.event.head_commit.message || '', '[skip ci]')
runs-on: ubuntu-latest
outputs:
published: ${{ steps.publish.outputs.published }}
version: ${{ steps.publish.outputs.version }}
tag: ${{ steps.publish.outputs.tag }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
token: ${{ secrets.GH_TOKEN }}
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install git-cliff
uses: taiki-e/install-action@v2
with:
tool: git-cliff
- name: Detect and publish
id: publish
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FORCE: ${{ inputs.force_publish }}
shell: bash
run: |
set -euo pipefail
# ══════════════════════════════════════════════════════════════════
# Helper functions
# ══════════════════════════════════════════════════════════════════
log() { echo "::group::$1"; }
endlog() { echo "::endgroup::"; }
info() { echo "ℹ️ $*"; }
success() { echo "✅ $*"; }
warn() { echo "::warning::$*"; }
err() { echo "::error::$*"; }
get_local_version() {
cargo metadata --no-deps --format-version=1 | \
jq -r --arg n "$1" '.packages[] | select(.name == $n and .source == null) | .version'
}
get_remote_version() {
curl -sS -A "masterror-ci/2.0" "https://crates.io/api/v1/crates/$1" 2>/dev/null | \
jq -r '.crate.max_version // "0.0.0"'
}
version_gt() {
local v1 v2
IFS='.' read -ra v1 <<< "$1"
IFS='.' read -ra v2 <<< "$2"
for i in 0 1 2; do
local a=${v1[i]:-0} b=${v2[i]:-0}
if [[ $a -gt $b ]]; then return 0; fi
if [[ $a -lt $b ]]; then return 1; fi
done
return 1
}
publish_crate() {
local crate=$1 attempt
log "Publishing $crate"
for attempt in {1..5}; do
info "Attempt $attempt/5"
if cargo publish -p "$crate" --locked 2>&1; then
success "$crate published!"
endlog
return 0
fi
sleep $((attempt * 5))
done
endlog
err "Failed to publish $crate"
return 1
}
# ══════════════════════════════════════════════════════════════════
# Version detection
# ══════════════════════════════════════════════════════════════════
log "Checking versions"
declare -A LOCAL REMOTE NEEDS_PUBLISH
PUBLISHED_ANY=false
for crate in masterror-template masterror-derive masterror; do
LOCAL[$crate]=$(get_local_version "$crate")
REMOTE[$crate]=$(get_remote_version "$crate")
if version_gt "${LOCAL[$crate]}" "${REMOTE[$crate]}" || [[ "$FORCE" == "true" ]]; then
NEEDS_PUBLISH[$crate]=true
info "$crate: ${LOCAL[$crate]} > ${REMOTE[$crate]} (will publish)"
else
NEEDS_PUBLISH[$crate]=false
info "$crate: ${LOCAL[$crate]} = ${REMOTE[$crate]} (skip)"
fi
done
endlog
# ══════════════════════════════════════════════════════════════════
# Dependency-aware publishing
# ══════════════════════════════════════════════════════════════════
# If derive changes, masterror should also be republished
# (it depends on derive, users need consistent versions)
if [[ "${NEEDS_PUBLISH[masterror-derive]}" == "true" ]] && \
[[ "${NEEDS_PUBLISH[masterror]}" == "false" ]]; then
warn "masterror-derive changed but masterror version unchanged"
warn "Consider bumping masterror version for dependency consistency"
fi
# Publish in order: template → derive → masterror
for crate in masterror-template masterror-derive masterror; do
if [[ "${NEEDS_PUBLISH[$crate]}" == "true" ]]; then
if publish_crate "$crate"; then
PUBLISHED_ANY=true
# Wait for crates.io index to sync
if [[ "$crate" != "masterror" ]]; then
info "Waiting 20s for crates.io index sync..."
sleep 20
fi
fi
fi
done
# ══════════════════════════════════════════════════════════════════
# GitHub Release
# ══════════════════════════════════════════════════════════════════
MASTERROR_VERSION="${LOCAL[masterror]}"
TAG="v$MASTERROR_VERSION"
if [[ "$PUBLISHED_ANY" == "true" ]] && [[ "${NEEDS_PUBLISH[masterror]}" == "true" ]]; then
log "Creating GitHub release"
# Create tag
if ! git rev-parse "$TAG" >/dev/null 2>&1; then
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" -m "Release $TAG"
git push origin "$TAG"
success "Created tag $TAG"
fi
# Generate release notes
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [[ -n "$PREV_TAG" ]]; then
NOTES=$(git cliff --config cliff.toml "$PREV_TAG"..HEAD --strip all 2>/dev/null || echo "Release $TAG")
else
NOTES=$(git cliff --config cliff.toml --strip all 2>/dev/null || echo "Release $TAG")
fi
# Create release
if ! gh release view "$TAG" >/dev/null 2>&1; then
gh release create "$TAG" --title "$TAG" --notes "$NOTES" --latest
success "Created GitHub release $TAG"
fi
endlog
fi
# ══════════════════════════════════════════════════════════════════
# Outputs
# ══════════════════════════════════════════════════════════════════
echo "published=$PUBLISHED_ANY" >> "$GITHUB_OUTPUT"
echo "version=$MASTERROR_VERSION" >> "$GITHUB_OUTPUT"
if [[ "${NEEDS_PUBLISH[masterror]}" == "true" ]]; then
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
fi
- name: Summary
if: always()
run: |
cat >> $GITHUB_STEP_SUMMARY << 'EOF'
## 📦 Release Summary
| Crate | Status |
|-------|--------|
| masterror-template | ${{ steps.publish.outputs.published == 'true' && '✅ Published' || '⏭️ Skipped' }} |
| masterror-derive | ${{ steps.publish.outputs.published == 'true' && '✅ Published' || '⏭️ Skipped' }} |
| masterror | ${{ steps.publish.outputs.published == 'true' && '✅ Published' || '⏭️ Skipped' }} |
**Version:** `${{ steps.publish.outputs.version }}`
EOF
# ════════════════════════════════════════════════════════════════════════════
# STAGE 4: POST-RELEASE (security artifacts)
# ════════════════════════════════════════════════════════════════════════════
artifacts:
name: Security Artifacts
needs: release
if: needs.release.outputs.published == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install tools
uses: taiki-e/install-action@v2
with:
tool: cargo-cyclonedx
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Generate SBOM
run: |
cargo cyclonedx --format json --all-features
mv masterror.cdx.json sbom.json
- name: Package crates
run: cargo package --locked
- name: Sign with Sigstore
run: |
cosign sign-blob --bundle sbom.cosign.bundle --yes sbom.json
for f in target/package/*.crate; do
[[ -f "$f" ]] && cosign sign-blob --bundle "${f}.cosign.bundle" --yes "$f"
done
- name: Generate attestations
uses: actions/attest-build-provenance@v1
with:
subject-path: |
sbom.json
target/package/*.crate
- name: Upload to release
if: needs.release.outputs.tag != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "${{ needs.release.outputs.tag }}" \
sbom.json sbom.cosign.bundle \
target/package/*.crate target/package/*.crate.cosign.bundle \
--clobber || true
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: security-artifacts
path: |
sbom.json
sbom.cosign.bundle
target/package/*.crate.cosign.bundle
retention-days: 90
# ════════════════════════════════════════════════════════════════════════════
# FINAL: Status check for branch protection
# ════════════════════════════════════════════════════════════════════════════
ci-success:
name: CI Success
needs: [check, fmt, docs, no-std, security, reuse, test]
if: always()
runs-on: ubuntu-latest
steps:
- name: Check all jobs
run: |
results="${{ needs.check.result }} ${{ needs.fmt.result }} ${{ needs.docs.result }} ${{ needs.no-std.result }} ${{ needs.security.result }} ${{ needs.reuse.result }} ${{ needs.test.result }}"
for r in $results; do
if [[ "$r" == "failure" ]]; then
echo "::error::One or more required jobs failed"
exit 1
fi
done
echo "✅ All CI checks passed!"