CI #763
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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!" |