feat(migrations): add compile-time migration generation #164
Workflow file for this run
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] | |
| pull_request: | |
| workflow_dispatch: | |
| inputs: | |
| force_publish: | |
| description: "Force publish (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 | |
| jobs: | |
| # ════════════════════════════════════════════════════════════════════════════ | |
| # STAGE 1: CHECKS (parallel) | |
| # ════════════════════════════════════════════════════════════════════════════ | |
| check: | |
| name: Check (${{ matrix.rust }} / ${{ matrix.os }}) | |
| runs-on: ${{ matrix.os }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| # MSRV | |
| - rust: "1.92" | |
| 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@v6 | |
| - name: Install Rust ${{ matrix.rust }} | |
| uses: dtolnay/rust-toolchain@master | |
| with: | |
| toolchain: ${{ matrix.rust }} | |
| components: clippy | |
| - name: Cache | |
| uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: ${{ matrix.rust }}-${{ matrix.os }} | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| - name: Clippy | |
| run: cargo clippy --all-targets --all-features -- -D warnings | |
| - name: Check (all features) | |
| run: cargo check --all-features | |
| - name: Check (no default features) | |
| run: cargo check --no-default-features | |
| fmt: | |
| name: Format | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - 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@v6 | |
| - name: Install Rust stable | |
| uses: dtolnay/rust-toolchain@stable | |
| - name: Cache | |
| uses: Swatinem/rust-cache@v2 | |
| - name: Build docs | |
| run: cargo doc --all-features --no-deps | |
| security: | |
| name: Security Audit | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Install Rust stable | |
| uses: dtolnay/rust-toolchain@stable | |
| - name: Install cargo-deny | |
| uses: taiki-e/install-action@v2 | |
| with: | |
| tool: cargo-deny | |
| - name: Security audit | |
| run: cargo deny check advisories | |
| reuse: | |
| name: REUSE Compliance | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - 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, security, reuse] | |
| if: ${{ !inputs.skip_tests }} | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Install Rust stable | |
| uses: dtolnay/rust-toolchain@stable | |
| - name: Cache | |
| uses: Swatinem/rust-cache@v2 | |
| continue-on-error: true | |
| 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 --all-features --profile ci --no-tests=pass | |
| - name: Run doctests | |
| run: cargo test --doc --all-features || true | |
| - name: Upload test results | |
| if: always() | |
| uses: actions/upload-artifact@v6 | |
| with: | |
| name: test-results | |
| path: target/nextest/ci/junit.xml | |
| if-no-files-found: ignore | |
| retention-days: 30 | |
| - name: Upload test results to Codecov | |
| if: ${{ !cancelled() }} | |
| uses: codecov/codecov-action@v5 | |
| with: | |
| token: ${{ secrets.CODECOV_TOKEN }} | |
| files: target/nextest/ci/junit.xml | |
| report_type: test_results | |
| fail_ci_if_error: false | |
| coverage: | |
| name: Coverage | |
| needs: test | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Install Rust stable | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| components: llvm-tools-preview | |
| - name: Cache | |
| uses: Swatinem/rust-cache@v2 | |
| continue-on-error: true | |
| 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 --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@v6 | |
| 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 | |
| # ════════════════════════════════════════════════════════════════════════════ | |
| # STAGE 3: RELEASE (after tests pass, main branch only) | |
| # ════════════════════════════════════════════════════════════════════════════ | |
| changelog: | |
| name: Update Changelog | |
| needs: [check, fmt, 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@v6 | |
| with: | |
| fetch-depth: 0 | |
| token: ${{ secrets.GH_TOKEN || secrets.GITHUB_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 | |
| 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' | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| 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, retrying..." | |
| git fetch origin main | |
| git reset --hard origin/main | |
| git cliff --config cliff.toml -o CHANGELOG.md | |
| sleep $((i * 2)) | |
| done | |
| echo "::warning::Failed to push changelog after 3 attempts" | |
| exit 0 | |
| release: | |
| name: Release | |
| needs: [check, fmt, docs, security, reuse, test, coverage, changelog] | |
| if: | | |
| always() && | |
| needs.check.result == 'success' && | |
| needs.fmt.result == 'success' && | |
| needs.docs.result == 'success' && | |
| needs.security.result == 'success' && | |
| needs.reuse.result == 'success' && | |
| (needs.test.result == 'success' || needs.test.result == 'skipped') && | |
| (needs.coverage.result == 'success' || needs.coverage.result == 'skipped') && | |
| needs.changelog.result == 'success' && | |
| (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.summary.outputs.published }} | |
| version: ${{ steps.summary.outputs.version }} | |
| tag: ${{ steps.summary.outputs.tag }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| token: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }} | |
| - name: Install Rust stable | |
| uses: dtolnay/rust-toolchain@stable | |
| - name: Cache | |
| uses: Swatinem/rust-cache@v2 | |
| - name: Publish crates | |
| id: publish | |
| env: | |
| CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| # Crates in dependency order | |
| CRATES=("entity-core" "entity-derive-impl" "entity-derive") | |
| PUBLISHED="" | |
| SKIPPED="" | |
| FAILED="" | |
| # Compare semver versions: returns 0 if $1 > $2 | |
| version_gt() { | |
| local v1=$1 v2=$2 | |
| if [[ "$v1" == "$v2" ]]; then return 1; fi | |
| local IFS=. | |
| local i v1_arr=($v1) v2_arr=($v2) | |
| for ((i=0; i<3; i++)); do | |
| local n1=${v1_arr[i]:-0} n2=${v2_arr[i]:-0} | |
| if ((n1 > n2)); then return 0; fi | |
| if ((n1 < n2)); then return 1; fi | |
| done | |
| return 1 | |
| } | |
| # Get version from crates.io (returns "0.0.0" if not found) | |
| get_crates_io_version() { | |
| local crate=$1 | |
| local response | |
| response=$(curl -s "https://crates.io/api/v1/crates/$crate" 2>/dev/null || echo "{}") | |
| local version | |
| version=$(echo "$response" | jq -r '.crate.max_version // "0.0.0"' 2>/dev/null || echo "0.0.0") | |
| if [[ "$version" == "null" || -z "$version" ]]; then | |
| echo "0.0.0" | |
| else | |
| echo "$version" | |
| fi | |
| } | |
| # Get local version from Cargo.toml | |
| get_local_version() { | |
| local crate_dir=$1 | |
| cargo metadata --format-version=1 --no-deps --manifest-path "$crate_dir/Cargo.toml" \ | |
| | jq -r '.packages[0].version' | |
| } | |
| echo "## Crate Publication Status" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "| Crate | Local | crates.io | Action |" >> $GITHUB_STEP_SUMMARY | |
| echo "|-------|-------|-----------|--------|" >> $GITHUB_STEP_SUMMARY | |
| for crate in "${CRATES[@]}"; do | |
| echo "::group::Processing $crate" | |
| crate_dir="crates/$crate" | |
| local_ver=$(get_local_version "$crate_dir") | |
| remote_ver=$(get_crates_io_version "$crate") | |
| echo "Local version: $local_ver" | |
| echo "crates.io version: $remote_ver" | |
| if version_gt "$local_ver" "$remote_ver"; then | |
| echo "Publishing $crate $local_ver (current: $remote_ver)..." | |
| if cargo publish --manifest-path "$crate_dir/Cargo.toml" --no-verify 2>&1 | tee /tmp/publish_${crate}.log; then | |
| echo "Successfully published $crate $local_ver" | |
| PUBLISHED="$PUBLISHED $crate:$local_ver" | |
| echo "| $crate | $local_ver | $remote_ver | Published |" >> $GITHUB_STEP_SUMMARY | |
| # Wait for crates.io to index before publishing dependent crates | |
| echo "Waiting for crates.io to index..." | |
| sleep 30 | |
| else | |
| if grep -q "already exists" /tmp/publish_${crate}.log; then | |
| echo "::notice::$crate $local_ver already exists" | |
| SKIPPED="$SKIPPED $crate:$local_ver" | |
| echo "| $crate | $local_ver | $remote_ver | Already exists |" >> $GITHUB_STEP_SUMMARY | |
| else | |
| echo "::error::Failed to publish $crate" | |
| cat /tmp/publish_${crate}.log | |
| FAILED="$FAILED $crate:$local_ver" | |
| echo "| $crate | $local_ver | $remote_ver | Failed |" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| fi | |
| elif [[ "$local_ver" == "$remote_ver" ]]; then | |
| echo "$crate $local_ver already published" | |
| SKIPPED="$SKIPPED $crate:$local_ver" | |
| echo "| $crate | $local_ver | $remote_ver | Up to date |" >> $GITHUB_STEP_SUMMARY | |
| else | |
| echo "::warning::$crate local ($local_ver) < crates.io ($remote_ver)" | |
| SKIPPED="$SKIPPED $crate:$local_ver" | |
| echo "| $crate | $local_ver | $remote_ver | Local older |" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| echo "::endgroup::" | |
| done | |
| # Set outputs | |
| if [[ -n "$PUBLISHED" ]]; then | |
| echo "published=true" >> "$GITHUB_OUTPUT" | |
| echo "published_crates=$PUBLISHED" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "published=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| if [[ -n "$FAILED" ]]; then | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "### Failed publications" >> $GITHUB_STEP_SUMMARY | |
| echo "$FAILED" >> $GITHUB_STEP_SUMMARY | |
| exit 1 | |
| fi | |
| - name: Detect version | |
| id: version | |
| run: | | |
| # Single source of truth: crates/entity-derive/Cargo.toml | |
| VERSION=$(grep '^version' crates/entity-derive/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" | |
| echo "Detected version: $VERSION" | |
| - name: Install git-cliff | |
| if: steps.publish.outputs.published == 'true' | |
| uses: taiki-e/install-action@v2 | |
| with: | |
| tool: git-cliff | |
| - name: Generate release notes | |
| id: notes | |
| if: steps.publish.outputs.published == 'true' | |
| env: | |
| TAG: ${{ steps.version.outputs.tag }} | |
| VERSION: ${{ steps.version.outputs.version }} | |
| PUBLISHED_CRATES: ${{ steps.publish.outputs.published_crates }} | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| git fetch --tags --force | |
| # Generate changelog from last tag | |
| LATEST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") | |
| echo "Latest tag: ${LATEST_TAG:-none}" | |
| if [ -n "$LATEST_TAG" ]; then | |
| NOTES=$(git cliff --config cliff.toml --unreleased --strip header --tag "$TAG" 2>/dev/null || echo "") | |
| else | |
| NOTES=$(git cliff --config cliff.toml --strip header --tag "$TAG" 2>/dev/null || echo "") | |
| fi | |
| # Fallback: extract from merged PR if changelog is empty | |
| if [ -z "$NOTES" ] || [ "$(echo "$NOTES" | wc -w)" -lt 10 ]; then | |
| echo "git-cliff output empty, extracting from merged PRs..." | |
| # Find merged PRs since last tag | |
| if [ -n "$LATEST_TAG" ]; then | |
| MERGE_COMMITS=$(git log "$LATEST_TAG"..HEAD --merges --oneline | grep -oE '#[0-9]+' | tr -d '#' | sort -u) | |
| else | |
| MERGE_COMMITS=$(git log --merges --oneline | head -10 | grep -oE '#[0-9]+' | tr -d '#' | sort -u) | |
| fi | |
| if [ -n "$MERGE_COMMITS" ]; then | |
| echo "Found PRs: $MERGE_COMMITS" | |
| echo "## What's Changed" > /tmp/release_notes.md | |
| echo "" >> /tmp/release_notes.md | |
| for PR_NUM in $MERGE_COMMITS; do | |
| echo "Fetching PR #$PR_NUM commits..." | |
| # Get PR commits with conventional commit format | |
| gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUM/commits" --jq '.[].commit.message' 2>/dev/null | \ | |
| grep -E '^(feat|fix|docs|style|refactor|perf|test|chore)' | \ | |
| head -1 | while read -r msg; do | |
| # Parse conventional commit | |
| TYPE=$(echo "$msg" | sed -E 's/^([a-z]+)(\([^)]+\))?:.*/\1/') | |
| SCOPE=$(echo "$msg" | sed -E 's/^[a-z]+\(([^)]+)\):.*/\1/' | grep -v '^feat\|^fix') | |
| DESC=$(echo "$msg" | sed -E 's/^[a-z]+(\([^)]+\))?:\s*//') | |
| case "$TYPE" in | |
| feat) EMOJI="+" ;; | |
| fix) EMOJI="*" ;; | |
| docs) EMOJI="@" ;; | |
| *) EMOJI="-" ;; | |
| esac | |
| if [ -n "$SCOPE" ] && [ "$SCOPE" != "$msg" ]; then | |
| echo "- **$SCOPE:** $DESC ([#$PR_NUM](https://github.com/$GITHUB_REPOSITORY/pull/$PR_NUM))" >> /tmp/release_notes.md | |
| else | |
| echo "- $DESC ([#$PR_NUM](https://github.com/$GITHUB_REPOSITORY/pull/$PR_NUM))" >> /tmp/release_notes.md | |
| fi | |
| done | |
| # If no conventional commits, use PR title | |
| if ! grep -q "#$PR_NUM" /tmp/release_notes.md 2>/dev/null; then | |
| PR_TITLE=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUM" --jq '.title' 2>/dev/null || echo "") | |
| if [ -n "$PR_TITLE" ]; then | |
| echo "- $PR_TITLE ([#$PR_NUM](https://github.com/$GITHUB_REPOSITORY/pull/$PR_NUM))" >> /tmp/release_notes.md | |
| fi | |
| fi | |
| done | |
| echo "" >> /tmp/release_notes.md | |
| echo "**Full Changelog**: https://github.com/$GITHUB_REPOSITORY/compare/$LATEST_TAG...$TAG" >> /tmp/release_notes.md | |
| else | |
| # Ultimate fallback | |
| echo "## Release $VERSION" > /tmp/release_notes.md | |
| echo "" >> /tmp/release_notes.md | |
| echo "Published crates: $PUBLISHED_CRATES" >> /tmp/release_notes.md | |
| echo "" >> /tmp/release_notes.md | |
| echo "**Full Changelog**: https://github.com/$GITHUB_REPOSITORY/compare/$LATEST_TAG...$TAG" >> /tmp/release_notes.md | |
| fi | |
| else | |
| echo "$NOTES" > /tmp/release_notes.md | |
| fi | |
| echo "::group::Release notes" | |
| cat /tmp/release_notes.md | |
| echo "::endgroup::" | |
| - name: Create git tag | |
| if: steps.publish.outputs.published == 'true' | |
| run: | | |
| TAG="${{ steps.version.outputs.tag }}" | |
| if git rev-parse "$TAG" >/dev/null 2>&1; then | |
| echo "Tag $TAG already exists" | |
| else | |
| 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" | |
| echo "Created tag $TAG" | |
| fi | |
| - name: Create GitHub Release | |
| if: steps.publish.outputs.published == 'true' | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| TAG="${{ steps.version.outputs.tag }}" | |
| VERSION="${{ steps.version.outputs.version }}" | |
| gh release create "$TAG" \ | |
| --title "Release $VERSION" \ | |
| --notes-file /tmp/release_notes.md \ | |
| --verify-tag || echo "Release already exists or creation failed" | |
| - name: Summary | |
| id: summary | |
| if: always() | |
| run: | | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "---" >> $GITHUB_STEP_SUMMARY | |
| if [ "${{ steps.publish.outputs.published }}" == "true" ]; then | |
| echo "### Published crates:" >> $GITHUB_STEP_SUMMARY | |
| echo "${{ steps.publish.outputs.published_crates }}" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "- [entity-derive on crates.io](https://crates.io/crates/entity-derive)" >> $GITHUB_STEP_SUMMARY | |
| echo "- [Documentation](https://docs.rs/entity-derive)" >> $GITHUB_STEP_SUMMARY | |
| echo "published=true" >> "$GITHUB_OUTPUT" | |
| echo "version=${{ steps.version.outputs.version }}" >> "$GITHUB_OUTPUT" | |
| echo "tag=${{ steps.version.outputs.tag }}" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "No new versions published." >> $GITHUB_STEP_SUMMARY | |
| echo "published=false" >> "$GITHUB_OUTPUT" | |
| fi |