Skip to content

feat: add diagnostic hints and suggestions system #812

feat: add diagnostic hints and suggestions system

feat: add diagnostic hints and suggestions system #812

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)
# template → derive → knowledge → masterror → cli
CRATES: "masterror-template masterror-derive masterror-knowledge masterror masterror-cli"
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"
# ════════════════════════════════════════════════════════════════════════════
# Detect changed crates (dependency-aware)
# ════════════════════════════════════════════════════════════════════════════
changes:
name: Detect Changes
runs-on: ubuntu-latest
outputs:
# Individual crate changes
template: ${{ steps.filter.outputs.template }}
derive: ${{ steps.filter.outputs.derive }}
knowledge: ${{ steps.filter.outputs.knowledge }}
masterror: ${{ steps.filter.outputs.masterror }}
cli: ${{ steps.filter.outputs.cli }}
# Dependency-aware: need to rebuild these
need-template: ${{ steps.deps.outputs.need-template }}
need-derive: ${{ steps.deps.outputs.need-derive }}
need-knowledge: ${{ steps.deps.outputs.need-knowledge }}
need-masterror: ${{ steps.deps.outputs.need-masterror }}
need-cli: ${{ steps.deps.outputs.need-cli }}
# Any library changed (for full workspace checks)
any-lib: ${{ steps.deps.outputs.any-lib }}
# CI/config changed (force full rebuild)
ci: ${{ steps.filter.outputs.ci }}
steps:
- uses: actions/checkout@v5
- name: Detect file changes
uses: dorny/paths-filter@v3
id: filter
with:
filters: |
template:
- 'masterror-template/**'
derive:
- 'masterror-derive/**'
knowledge:
- 'masterror-knowledge/**'
masterror:
- 'src/**'
- 'Cargo.toml'
- 'build.rs'
cli:
- 'masterror-cli/**'
ci:
- '.github/workflows/**'
- 'Cargo.lock'
- 'deny.toml'
- 'cliff.toml'
- '.cargo/**'
- name: Compute dependency graph
id: deps
run: |
# Dependency graph:
# template ← derive ← masterror
# ↗
# knowledge ← cli
# ↘ masterror (optional feature)
TEMPLATE="${{ steps.filter.outputs.template }}"
DERIVE="${{ steps.filter.outputs.derive }}"
KNOWLEDGE="${{ steps.filter.outputs.knowledge }}"
MASTERROR="${{ steps.filter.outputs.masterror }}"
CLI="${{ steps.filter.outputs.cli }}"
CI="${{ steps.filter.outputs.ci }}"
# Force all if CI config changed or workflow_dispatch
if [[ "$CI" == "true" ]] || [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "CI config changed or manual trigger - rebuilding all"
echo "need-template=true" >> "$GITHUB_OUTPUT"
echo "need-derive=true" >> "$GITHUB_OUTPUT"
echo "need-knowledge=true" >> "$GITHUB_OUTPUT"
echo "need-masterror=true" >> "$GITHUB_OUTPUT"
echo "need-cli=true" >> "$GITHUB_OUTPUT"
echo "any-lib=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# template: standalone
NEED_TEMPLATE="$TEMPLATE"
# derive: depends on template
if [[ "$DERIVE" == "true" ]] || [[ "$TEMPLATE" == "true" ]]; then
NEED_DERIVE=true
else
NEED_DERIVE=false
fi
# knowledge: standalone
NEED_KNOWLEDGE="$KNOWLEDGE"
# masterror: depends on template, derive, optionally knowledge
if [[ "$MASTERROR" == "true" ]] || [[ "$TEMPLATE" == "true" ]] || \
[[ "$DERIVE" == "true" ]] || [[ "$KNOWLEDGE" == "true" ]]; then
NEED_MASTERROR=true
else
NEED_MASTERROR=false
fi
# cli: depends on knowledge
if [[ "$CLI" == "true" ]] || [[ "$KNOWLEDGE" == "true" ]]; then
NEED_CLI=true
else
NEED_CLI=false
fi
# Any library changed?
if [[ "$NEED_TEMPLATE" == "true" ]] || [[ "$NEED_DERIVE" == "true" ]] || \
[[ "$NEED_KNOWLEDGE" == "true" ]] || [[ "$NEED_MASTERROR" == "true" ]] || \
[[ "$NEED_CLI" == "true" ]]; then
ANY_LIB=true
else
ANY_LIB=false
fi
echo "need-template=$NEED_TEMPLATE" >> "$GITHUB_OUTPUT"
echo "need-derive=$NEED_DERIVE" >> "$GITHUB_OUTPUT"
echo "need-knowledge=$NEED_KNOWLEDGE" >> "$GITHUB_OUTPUT"
echo "need-masterror=$NEED_MASTERROR" >> "$GITHUB_OUTPUT"
echo "need-cli=$NEED_CLI" >> "$GITHUB_OUTPUT"
echo "any-lib=$ANY_LIB" >> "$GITHUB_OUTPUT"
echo "Summary:"
echo " template: $TEMPLATE → need: $NEED_TEMPLATE"
echo " derive: $DERIVE → need: $NEED_DERIVE"
echo " knowledge: $KNOWLEDGE → need: $NEED_KNOWLEDGE"
echo " masterror: $MASTERROR → need: $NEED_MASTERROR"
echo " cli: $CLI → need: $NEED_CLI"
echo " any-lib: $ANY_LIB"
# GitHub Step Summary
cat >> $GITHUB_STEP_SUMMARY << EOF
## 🔍 Change Detection
| Crate | Changed | Rebuild |
|-------|---------|---------|
| masterror-template | $([[ "$TEMPLATE" == "true" ]] && echo "✅" || echo "—") | $([[ "$NEED_TEMPLATE" == "true" ]] && echo "🔨" || echo "⏭️") |
| masterror-derive | $([[ "$DERIVE" == "true" ]] && echo "✅" || echo "—") | $([[ "$NEED_DERIVE" == "true" ]] && echo "🔨" || echo "⏭️") |
| masterror-knowledge | $([[ "$KNOWLEDGE" == "true" ]] && echo "✅" || echo "—") | $([[ "$NEED_KNOWLEDGE" == "true" ]] && echo "🔨" || echo "⏭️") |
| masterror | $([[ "$MASTERROR" == "true" ]] && echo "✅" || echo "—") | $([[ "$NEED_MASTERROR" == "true" ]] && echo "🔨" || echo "⏭️") |
| masterror-cli | $([[ "$CLI" == "true" ]] && echo "✅" || echo "—") | $([[ "$NEED_CLI" == "true" ]] && echo "🔨" || echo "⏭️") |
**Legend:** ✅ = changed, 🔨 = will rebuild, ⏭️ = skipped, — = no changes
EOF
# ════════════════════════════════════════════════════════════════════════════
# STAGE 1: CHECKS (parallel matrix)
# ════════════════════════════════════════════════════════════════════════════
check:
name: Check (${{ matrix.rust }} / ${{ matrix.os }})
needs: [msrv, changes]
if: needs.changes.outputs.any-lib == 'true'
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
needs: changes
if: needs.changes.outputs.any-lib == 'true'
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
needs: changes
if: needs.changes.outputs.any-lib == 'true'
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 }})
needs: changes
if: needs.changes.outputs.need-masterror == 'true'
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 -p masterror ${{ 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: [changes, check, fmt, no-std, security, reuse]
if: |
always() &&
!inputs.skip_tests &&
needs.changes.outputs.any-lib == 'true' &&
(needs.check.result == 'success' || needs.check.result == 'skipped') &&
(needs.fmt.result == 'success' || needs.fmt.result == 'skipped') &&
(needs['no-std'].result == 'success' || needs['no-std'].result == 'skipped') &&
needs.security.result == 'success' &&
needs.reuse.result == 'success'
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: [changes, test]
if: |
always() &&
needs.changes.outputs.any-lib == 'true' &&
needs.test.result == 'success'
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: [changes, test]
runs-on: ubuntu-latest
if: |
always() &&
needs.changes.outputs.need-masterror == 'true' &&
needs.test.result == 'success' &&
(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: [changes, check, fmt, no-std, security, reuse]
runs-on: ubuntu-latest
if: |
always() &&
(github.event_name == 'push' || github.event_name == 'workflow_dispatch') &&
github.ref == 'refs/heads/main' &&
!contains(github.event.head_commit.message || '', '[skip ci]') &&
(needs.check.result == 'success' || needs.check.result == 'skipped') &&
(needs.fmt.result == 'success' || needs.fmt.result == 'skipped') &&
(needs['no-std'].result == 'success' || needs['no-std'].result == 'skipped') &&
needs.security.result == 'success' &&
needs.reuse.result == 'success'
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: [changes, test, changelog]
if: |
always() &&
needs.changes.outputs.any-lib == 'true' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.changelog.result == 'success' || needs.changelog.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 $CRATES; 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
# ══════════════════════════════════════════════════════════════════
# Dependency consistency warnings
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
if [[ "${NEEDS_PUBLISH[masterror-knowledge]}" == "true" ]] && \
[[ "${NEEDS_PUBLISH[masterror-cli]}" == "false" ]]; then
warn "masterror-knowledge changed but masterror-cli version unchanged"
warn "Consider bumping masterror-cli version for dependency consistency"
fi
# Publish in order: template → derive → knowledge → masterror → cli
for crate in $CRATES; 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-knowledge | ${{ steps.publish.outputs.published == 'true' && '✅ Published' || '⏭️ Skipped' }} |
| masterror | ${{ steps.publish.outputs.published == 'true' && '✅ Published' || '⏭️ Skipped' }} |
| masterror-cli | ${{ 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: [changes, check, fmt, docs, no-std, security, reuse, test]
if: always()
runs-on: ubuntu-latest
steps:
- name: Check all jobs
run: |
echo "Job results:"
echo " changes: ${{ needs.changes.result }}"
echo " check: ${{ needs.check.result }}"
echo " fmt: ${{ needs.fmt.result }}"
echo " docs: ${{ needs.docs.result }}"
echo " no-std: ${{ needs['no-std'].result }}"
echo " security: ${{ needs.security.result }}"
echo " reuse: ${{ needs.reuse.result }}"
echo " test: ${{ needs.test.result }}"
FAILED=false
# Changes detection must succeed
[[ "${{ needs.changes.result }}" != "success" ]] && \
echo "::error::Changes detection failed" && FAILED=true
# Security and REUSE must always pass
[[ "${{ needs.security.result }}" == "failure" ]] && \
echo "::error::Security audit failed" && FAILED=true
[[ "${{ needs.reuse.result }}" == "failure" ]] && \
echo "::error::REUSE compliance failed" && FAILED=true
# Other jobs: failure is not OK (skipped is fine)
[[ "${{ needs.check.result }}" == "failure" ]] && \
echo "::error::Check job failed" && FAILED=true
[[ "${{ needs.fmt.result }}" == "failure" ]] && \
echo "::error::Format job failed" && FAILED=true
[[ "${{ needs.docs.result }}" == "failure" ]] && \
echo "::error::Docs job failed" && FAILED=true
[[ "${{ needs['no-std'].result }}" == "failure" ]] && \
echo "::error::no-std job failed" && FAILED=true
[[ "${{ needs.test.result }}" == "failure" ]] && \
echo "::error::Test job failed" && FAILED=true
if [[ "$FAILED" == "true" ]]; then
exit 1
fi
echo "✅ All CI checks passed (some may have been skipped due to no changes)"