Skip to content

feat(migrations): add compile-time migration generation #164

feat(migrations): add compile-time migration generation

feat(migrations): add compile-time migration generation #164

Workflow file for this run

# 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