diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b89b730..be79d67 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,14 @@ on: types: [opened, synchronize, reopened] workflow_dispatch: inputs: + release_mode: + description: 'Manual release mode' + required: true + type: choice + default: 'instant' + options: + - instant + - changelog-pr bump_type: description: 'Version bump type' required: true @@ -28,30 +36,112 @@ concurrency: env: CARGO_TERM_COLOR: always RUSTFLAGS: -Dwarnings + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }} + CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} jobs: - # REQUIRED CI CHECKS - All must pass before release - # These jobs ensure code quality and tests pass before any release + # === DETECT CHANGES - determines which jobs should run === + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + if: github.event_name != 'workflow_dispatch' + outputs: + rs-changed: ${{ steps.changes.outputs.rs-changed }} + toml-changed: ${{ steps.changes.outputs.toml-changed }} + docs-changed: ${{ steps.changes.outputs.docs-changed }} + workflow-changed: ${{ steps.changes.outputs.workflow-changed }} + any-code-changed: ${{ steps.changes.outputs.any-code-changed }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: cargo install rust-script + + - name: Detect changes + id: changes + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: rust-script scripts/detect-code-changes.rs + + # === CHANGELOG CHECK - only runs on PRs with code changes === + changelog: + name: Changelog Fragment Check + runs-on: ubuntu-latest + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: cargo install rust-script + + - name: Check for changelog fragments + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: rust-script scripts/check-changelog-fragment.rs - # Linting and formatting + # === VERSION CHECK - prevents manual version modification in PRs === + version-check: + name: Version Modification Check + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: cargo install rust-script + + - name: Check for manual version changes + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_BASE_REF: ${{ github.base_ref }} + run: rust-script scripts/check-version-modification.rs + + # === LINT AND FORMAT CHECK === lint: name: Lint and Format Check runs-on: ubuntu-latest + needs: [detect-changes] + if: | + always() && !cancelled() && ( + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.rs-changed == 'true' || + needs.detect-changes.outputs.toml-changed == 'true' || + needs.detect-changes.outputs.docs-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' + ) steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Rust uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20.x' + - name: Install rust-script + run: cargo install rust-script - name: Cache cargo registry - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -68,24 +158,26 @@ jobs: run: cargo clippy --all-targets --all-features - name: Check file size limit - run: node scripts/check-file-size.mjs + run: rust-script scripts/check-file-size.rs - # Test on multiple OS + # === TEST === test: name: Test (${{ matrix.os }}) runs-on: ${{ matrix.os }} + needs: [detect-changes, changelog] + if: always() && !cancelled() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changelog.result == 'success' || needs.changelog.result == 'skipped') strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Rust uses: dtolnay/rust-toolchain@stable - name: Cache cargo registry - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -101,25 +193,28 @@ jobs: - name: Run doc tests run: cargo test --doc --verbose - # Test coverage - ensures we maintain high test coverage + # === CODE COVERAGE === coverage: - name: Test Coverage + name: Code Coverage runs-on: ubuntu-latest + needs: [detect-changes] + if: | + always() && !cancelled() && ( + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.rs-changed == 'true' || + needs.detect-changes.outputs.toml-changed == 'true' + ) steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Rust uses: dtolnay/rust-toolchain@stable - - # Install tarpaulin using pre-built binary (avoids compilation issues) - - name: Install cargo-tarpaulin - run: | - curl -sL https://github.com/xd009642/tarpaulin/releases/download/0.31.5/cargo-tarpaulin-x86_64-unknown-linux-gnu.tar.gz | tar xz - chmod +x cargo-tarpaulin - sudo mv cargo-tarpaulin /usr/local/bin/ + with: + components: llvm-tools-preview - name: Cache cargo registry - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -129,36 +224,32 @@ jobs: restore-keys: | ${{ runner.os }}-cargo-coverage- - - name: Run coverage - run: cargo-tarpaulin --out Xml --output-dir coverage + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov - - name: Check coverage threshold - run: | - # Extract coverage percentage from output - COVERAGE=$(cargo-tarpaulin --out Stdout 2>&1 | grep -oP '\d+\.\d+(?=% coverage)' | tail -1) - echo "Coverage: $COVERAGE%" - - # Check if coverage meets threshold (90%) - if (( $(echo "$COVERAGE < 90" | bc -l) )); then - echo "::error::Coverage ($COVERAGE%) is below 90% threshold" - exit 1 - fi + - name: Generate code coverage + run: cargo llvm-cov --all-features --lcov --output-path lcov.info - echo "Coverage check passed: $COVERAGE% >= 90%" + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: lcov.info + fail_ci_if_error: false - # Build package - only runs if lint and test pass + # === BUILD === build: name: Build Package runs-on: ubuntu-latest needs: [lint, test] + if: always() && !cancelled() && needs.lint.result == 'success' && needs.test.result == 'success' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Rust uses: dtolnay/rust-toolchain@stable - name: Cache cargo registry - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -172,54 +263,22 @@ jobs: run: cargo build --release --verbose - name: Check package - run: cargo package --list + run: cargo package --list --allow-dirty - # Check for changelog fragments in PRs - changelog: - name: Changelog Fragment Check - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Check for changelog fragments - run: | - # Get list of fragment files (excluding README and template) - FRAGMENTS=$(find changelog.d -name "*.md" ! -name "README.md" 2>/dev/null | wc -l) - - # Get changed files in PR - CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) - - # Check if any source files changed (excluding docs and config) - SOURCE_CHANGED=$(echo "$CHANGED_FILES" | grep -E "^(src/|tests/|scripts/|examples/)" | wc -l) - - if [ "$SOURCE_CHANGED" -gt 0 ] && [ "$FRAGMENTS" -eq 0 ]; then - echo "::warning::No changelog fragment found. Please add a changelog entry in changelog.d/" - echo "" - echo "To create a changelog fragment:" - echo " Create a new .md file in changelog.d/ with your changes" - echo "" - echo "See changelog.d/README.md for more information." - # Note: This is a warning, not a failure, to allow flexibility - # Change 'exit 0' to 'exit 1' to make it required - exit 0 - fi - - echo "Changelog check passed" - - # Automatic release on push to main using changelog fragments - # This job automatically bumps version based on fragments in changelog.d/ + # === AUTO RELEASE === auto-release: name: Auto Release needs: [lint, test, build] - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: | + always() && !cancelled() && + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + needs.build.result == 'success' runs-on: ubuntu-latest permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -227,78 +286,71 @@ jobs: - name: Setup Rust uses: dtolnay/rust-toolchain@stable - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20.x' + - name: Install rust-script + run: cargo install rust-script - name: Configure git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + run: rust-script scripts/git-config.rs - name: Determine bump type from changelog fragments id: bump_type - run: node scripts/get-bump-type.mjs + run: rust-script scripts/get-bump-type.rs - name: Check if version already released or no fragments id: check - run: | - # Check if there are changelog fragments - if [ "${{ steps.bump_type.outputs.has_fragments }}" != "true" ]; then - # No fragments - check if current version tag exists - CURRENT_VERSION=$(grep -Po '(?<=^version = ")[^"]*' Cargo.toml) - if git rev-parse "v$CURRENT_VERSION" >/dev/null 2>&1; then - echo "No changelog fragments and v$CURRENT_VERSION already released" - echo "should_release=false" >> $GITHUB_OUTPUT - else - echo "No changelog fragments but v$CURRENT_VERSION not yet released" - echo "should_release=true" >> $GITHUB_OUTPUT - echo "skip_bump=true" >> $GITHUB_OUTPUT - fi - else - echo "Found changelog fragments, proceeding with release" - echo "should_release=true" >> $GITHUB_OUTPUT - echo "skip_bump=false" >> $GITHUB_OUTPUT - fi + env: + HAS_FRAGMENTS: ${{ steps.bump_type.outputs.has_fragments }} + run: rust-script scripts/check-release-needed.rs - name: Collect changelog and bump version id: version if: steps.check.outputs.should_release == 'true' && steps.check.outputs.skip_bump != 'true' run: | - node scripts/version-and-commit.mjs \ + rust-script scripts/version-and-commit.rs \ --bump-type "${{ steps.bump_type.outputs.bump_type }}" - name: Get current version id: current_version if: steps.check.outputs.should_release == 'true' - run: | - CURRENT_VERSION=$(grep -Po '(?<=^version = ")[^"]*' Cargo.toml) - echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + run: rust-script scripts/get-version.rs - name: Build release if: steps.check.outputs.should_release == 'true' run: cargo build --release + - name: Publish to Crates.io + if: steps.check.outputs.should_release == 'true' + id: publish-crate + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }} + CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} + run: rust-script scripts/publish-crate.rs + - name: Create GitHub Release if: steps.check.outputs.should_release == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - node scripts/create-github-release.mjs \ - --release-version "${{ steps.current_version.outputs.version }}" \ - --repository "${{ github.repository }}" + RELEASE_VERSION="${{ steps.version.outputs.new_version }}" + if [ -z "$RELEASE_VERSION" ]; then + RELEASE_VERSION="${{ steps.current_version.outputs.version }}" + fi + rust-script scripts/create-github-release.rs --release-version "$RELEASE_VERSION" --repository "${{ github.repository }}" - # Manual release via workflow_dispatch - only after CI passes + # === MANUAL INSTANT RELEASE === manual-release: - name: Manual Release + name: Instant Release needs: [lint, test, build] - if: github.event_name == 'workflow_dispatch' + if: | + always() && !cancelled() && + github.event_name == 'workflow_dispatch' && + github.event.inputs.release_mode == 'instant' && + needs.build.result == 'success' runs-on: ubuntu-latest permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -306,43 +358,115 @@ jobs: - name: Setup Rust uses: dtolnay/rust-toolchain@stable - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20.x' + - name: Install rust-script + run: cargo install rust-script - name: Configure git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + run: rust-script scripts/git-config.rs - name: Collect changelog fragments - run: | - # Check if there are any fragments to collect - FRAGMENTS=$(find changelog.d -name "*.md" ! -name "README.md" 2>/dev/null | wc -l) - if [ "$FRAGMENTS" -gt 0 ]; then - echo "Found $FRAGMENTS changelog fragment(s), collecting..." - node scripts/collect-changelog.mjs - else - echo "No changelog fragments found, skipping collection" - fi + run: rust-script scripts/collect-changelog.rs - name: Version and commit id: version - run: | - node scripts/version-and-commit.mjs \ - --bump-type "${{ github.event.inputs.bump_type }}" \ - --description "${{ github.event.inputs.description }}" + env: + BUMP_TYPE: ${{ github.event.inputs.bump_type }} + DESCRIPTION: ${{ github.event.inputs.description }} + run: rust-script scripts/version-and-commit.rs --bump-type "${{ github.event.inputs.bump_type }}" --description "${{ github.event.inputs.description }}" - name: Build release if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' run: cargo build --release + - name: Publish to Crates.io + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + id: publish-crate + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }} + CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} + run: rust-script scripts/publish-crate.rs + - name: Create GitHub Release if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - node scripts/create-github-release.mjs \ - --release-version "${{ steps.version.outputs.new_version }}" \ - --repository "${{ github.repository }}" + run: rust-script scripts/create-github-release.rs --release-version "${{ steps.version.outputs.new_version }}" --repository "${{ github.repository }}" + + # === DEPLOY DOCUMENTATION === + deploy-docs: + name: Deploy Rust Documentation + needs: [auto-release, manual-release] + if: | + always() && !cancelled() && ( + needs.auto-release.result == 'success' || + needs.manual-release.result == 'success' + ) + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + ref: main + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build documentation + run: cargo doc --no-deps --all-features + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: target/doc + destination_dir: rust + keep_files: true + + # === MANUAL CHANGELOG PR === + changelog-pr: + name: Create Changelog PR + if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changelog-pr' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: cargo install rust-script + + - name: Create changelog fragment + env: + BUMP_TYPE: ${{ github.event.inputs.bump_type }} + DESCRIPTION: ${{ github.event.inputs.description }} + run: rust-script scripts/create-changelog-fragment.rs --bump-type "${{ github.event.inputs.bump_type }}" --description "${{ github.event.inputs.description }}" + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'chore: add changelog for manual ${{ github.event.inputs.bump_type }} release' + branch: changelog-manual-release-${{ github.run_id }} + delete-branch: true + title: 'chore: manual ${{ github.event.inputs.bump_type }} release' + body: | + ## Manual Release Request + + This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release. + + ### Release Details + - **Type:** ${{ github.event.inputs.bump_type }} + - **Description:** ${{ github.event.inputs.description || 'Manual release' }} + - **Triggered by:** @${{ github.actor }} + + ### Next Steps + 1. Review the changelog fragment in this PR + 2. Merge this PR to main + 3. The automated release workflow will publish to crates.io and create a GitHub release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57a583e..851f59b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,8 +7,8 @@ Thank you for your interest in contributing! This document provides guidelines a 1. **Fork and clone the repository** ```bash - git clone https://github.com/YOUR-USERNAME/core-rs.git - cd core-rs + git clone https://github.com/YOUR-USERNAME/data-rs.git + cd data-rs ``` 2. **Install Rust** @@ -23,6 +23,7 @@ Thank you for your interest in contributing! This document provides guidelines a ```bash rustup component add rustfmt clippy + cargo install rust-script ``` 4. **Install pre-commit hooks** (optional but recommended) @@ -62,10 +63,10 @@ Thank you for your interest in contributing! This document provides guidelines a cargo clippy --all-targets --all-features # Check file sizes - node scripts/check-file-size.mjs + rust-script scripts/check-file-size.rs # Run all checks together - cargo fmt --check && cargo clippy --all-targets --all-features && node scripts/check-file-size.mjs + cargo fmt --check && cargo clippy --all-targets --all-features && rust-script scripts/check-file-size.rs ``` 4. **Run tests** @@ -259,7 +260,8 @@ Fragments are automatically collected into CHANGELOG.md during the release proce ├── changelog.d/ # Changelog fragments │ ├── README.md # Fragment instructions │ └── *.md # Individual changelog fragments -├── scripts/ # Utility scripts +├── docs/ # Documentation and case studies +├── scripts/ # Rust CI/CD automation scripts ├── src/ # Library source code ├── tests/ # Integration tests ├── .gitignore # Git ignore patterns @@ -281,8 +283,8 @@ This project uses semantic versioning (MAJOR.MINOR.PATCH): Releases are managed through GitHub releases. To trigger a release: -1. Manually trigger the release workflow with a version bump type -2. Or: Update the version in Cargo.toml and push to main +1. Push to main with changelog fragments for automatic release +2. Or: Manually trigger the release workflow with a version bump type ## Getting Help diff --git a/Cargo.lock b/Cargo.lock index 0fc8fd9..e7ccac5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "aho-corasick" @@ -11,12 +11,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "beef" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + [[package]] name = "cfg-if" version = "1.0.4" @@ -24,15 +36,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "env_logger" -version = "0.8.4" +name = "env_filter" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a19187fea3ac7e84da7dacf48de0c45d63c6a76f9490dae389aead16c243fce3" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" dependencies = [ "log", "regex", ] +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "env_filter", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "funty" version = "2.0.0" @@ -41,35 +75,86 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "getrandom" -version = "0.2.16" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "wasi", + "r-efi", + "rand_core", + "wasip2", + "wasip3", ] [[package]] -name = "libc" -version = "0.2.178" +name = "hashbrown" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] [[package]] -name = "log" -version = "0.4.17" +name = "hashbrown" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ - "cfg-if", + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "platform-data" @@ -82,20 +167,30 @@ dependencies = [ "thiserror", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" -version = "1.0.43" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a2ca2c61bc9f3d74d2886294ab7b9853abd9c1ad903a3ac7815c58989bb7bab" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quickcheck" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "588f6378e4dd99458b60ec275b4477add41ce4fa9f64dcba6f15adccb19b50d6" +checksum = "95c589f335db0f6aaa168a7cd27b1fc6920f5e1470c804f814d9cd6e62a0f70b" dependencies = [ "env_logger", "log", @@ -104,9 +199,9 @@ dependencies = [ [[package]] name = "quickcheck_macros" -version = "1.0.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b22a693222d716a9587786f37ac3f6b4faedb5b80c23914e7303ff5a1d8016e9" +checksum = "a9a28b8493dd664c8b171dd944da82d933f7d456b829bfb236738e1fe06c5ba4" dependencies = [ "proc-macro2", "quote", @@ -115,36 +210,40 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.21" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" -version = "0.8.5" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ + "getrandom", "rand_core", ] [[package]] name = "rand_core" -version = "0.6.4" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -154,9 +253,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -165,15 +264,63 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] [[package]] name = "syn" -version = "1.0.99" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58dbef6ec655055e20b86b15a8cc6d439cca19b667537ac6a1369572d151ab13" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -182,18 +329,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.31" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.31" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -202,12 +349,158 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] [[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 531a41a..515c8f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,20 +1,40 @@ [package] name = "platform-data" version = "0.1.0-beta.3" -edition = "2018" +edition = "2024" +rust-version = "1.85" authors = ["uselesssgoddess", "Linksplatform Team "] license = "Unlicense" -repository = "https://github.com/linksplatform/platform-rs" -homepage = "https://github.com/linksplatform/platform-rs" -description = """ -Data for linksplatform -""" +repository = "https://github.com/linksplatform/data-rs" +homepage = "https://github.com/linksplatform/data-rs" +description = "Data types and traits for the LinksPlatform" +readme = "README.md" +keywords = ["links", "linksplatform", "data-structures", "associative"] +categories = ["data-structures"] [dependencies] -beef = "~0.5" +beef = "0.5.2" funty = "2.0.0" -thiserror = "1.0.31" +thiserror = "2.0.18" [dev-dependencies] -quickcheck = "1.0.3" -quickcheck_macros = "1.0.0" \ No newline at end of file +quickcheck = "1.1.0" +quickcheck_macros = "1.2.0" + +[lints.rust] +unsafe_code = "allow" + +[lints.clippy] +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +module_name_repetitions = "allow" +too_many_lines = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" +missing_safety_doc = "allow" + +[profile.release] +lto = true +codegen-units = 1 +strip = true diff --git a/README.md b/README.md index 54e01be..8a9beca 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ [![Crates.io](https://img.shields.io/crates/v/platform-data.svg?label=crates.io&style=flat)](https://crates.io/crates/platform-data) [![Docs.rs](https://docs.rs/platform-data/badge.svg)](https://docs.rs/platform-data) -[![License: Unlicense](https://img.shields.io/badge/license-Unlicense-blue.svg)](https://github.com/linksplatform/core-rs/blob/main/LICENSE) +[![License: Unlicense](https://img.shields.io/badge/license-Unlicense-blue.svg)](https://github.com/linksplatform/data-rs/blob/main/LICENSE) +[![CI](https://github.com/linksplatform/data-rs/actions/workflows/release.yml/badge.svg)](https://github.com/linksplatform/data-rs/actions/workflows/release.yml) -# [Data](https://github.com/linksplatform/core-rs) +# [Data](https://github.com/linksplatform/data-rs) LinksPlatform's Platform.Data Class Library for Rust. @@ -156,7 +157,7 @@ use platform_data::{Links, LinkType, LinksConstants, Flow, Error, ReadHandler, W ## Requirements -This crate requires **Rust 1.79 or later** (stable toolchain). The `associated_type_bounds` feature used for `Error:` bounds was stabilized in Rust 1.79. +This crate requires **Rust 1.85 or later** (stable toolchain, edition 2024). ## Dependencies @@ -164,6 +165,12 @@ This crate requires **Rust 1.79 or later** (stable toolchain). The `associated_t - [funty](https://crates.io/crates/funty) — Fundamental type unification - [thiserror](https://crates.io/crates/thiserror) — Derive macro for error types +## Documentation + +API documentation is available on [docs.rs](https://docs.rs/platform-data). + +Generated documentation is also deployed to GitHub Pages after each release. + ## Related Projects - [mem-rs](https://github.com/linksplatform/mem-rs) — Memory management for Links Platform diff --git a/changelog.d/20260414_quality_audit_best_practices.md b/changelog.d/20260414_quality_audit_best_practices.md new file mode 100644 index 0000000..0af6e43 --- /dev/null +++ b/changelog.d/20260414_quality_audit_best_practices.md @@ -0,0 +1,18 @@ +--- +bump: minor +--- + +### Changed +- Upgraded to Rust edition 2024 with MSRV 1.85 +- Updated all dependencies to latest stable versions (thiserror 2.x, quickcheck 1.1, quickcheck_macros 1.2) +- Migrated CI/CD scripts from Node.js to Rust (rust-script) +- Upgraded CI/CD workflow with change detection, version check, Codecov coverage, and GitHub Pages docs deployment +- Added clippy pedantic and nursery lints configuration +- Added release profile optimization (LTO, single codegen unit, symbol stripping) +- Fixed repository URL and package metadata + +### Added +- Converter roundtrip tests and error variant tests (94 total test cases) +- GitHub Pages documentation deployment +- Crates.io publishing support in CI/CD +- Case study document for issue #14 diff --git a/docs/case-studies/issue-14/README.md b/docs/case-studies/issue-14/README.md new file mode 100644 index 0000000..22dbb4c --- /dev/null +++ b/docs/case-studies/issue-14/README.md @@ -0,0 +1,93 @@ +# Case Study: Issue #14 - Quality Audit and Best Practices Alignment + +## Issue Summary + +Double-check that code uses highest quality implementation for its current API, uses latest +stable Rust features and dependency versions, follows CI/CD best practices from reference +repositories, and has comprehensive documentation. + +## Requirements Analysis + +### R1: No Unstable Rust Features +- **Status**: Already satisfied. `rust-toolchain.toml` specifies `channel = "stable"`. +- **Action**: Upgrade from edition 2018 to edition 2024 (latest stable). +- **Action**: Set `rust-version = "1.85"` (MSRV for edition 2024). + +### R2: Latest Dependency Versions +- **Current**: `beef ~0.5`, `funty 2.0.0`, `thiserror 1.0.31`, `quickcheck 1.0.3`, `quickcheck_macros 1.0.0` +- **Latest**: `beef 0.5.2`, `funty 2.0.0` (3.0.0 is RC only), `thiserror 2.0.18`, `quickcheck 1.1.0`, `quickcheck_macros 1.2.0` +- **Action**: Update all dependencies to latest stable versions. +- **Note**: `thiserror` 2.x has breaking changes (MSRV 1.61+), requires testing. + +### R3: Documentation In Sync With Code +- **Current**: README.md exists, doc comments exist on most public items. +- **Action**: Ensure all public items have comprehensive doc comments with examples. +- **Action**: Add `#![warn(missing_docs)]` to enforce documentation coverage. + +### R4: Tests Not in src/ Folder +- **Status**: Already satisfied. All tests are in `tests/` directory, no `#[cfg(test)]` in `src/`. + +### R5: Increase Test Coverage +- **Current**: 94.57% coverage with 76+ test cases. +- **Action**: Add tests for uncovered edge cases (converters module, error variants). + +### R6: Automated Documentation Generation +- **Reference**: trees-rs deploys to GitHub Pages via `peaceiris/actions-gh-pages@v4`. +- **Action**: Add `deploy-docs` job to CI/CD workflow. + +### R7: CI/CD Best Practices Alignment +- **Reference repos**: `mem-rs`, `trees-rs`, `Numbers`, `rust-ai-driven-development-pipeline-template` +- **Missing features**: + - Change detection (skip unnecessary jobs for docs-only PRs) + - Version modification check (prevent manual version changes in PRs) + - `cargo-llvm-cov` + Codecov integration (instead of tarpaulin) + - Rust scripts via `rust-script` (instead of Node.js mjs scripts) + - `deploy-docs` job for GitHub Pages + - `changelog-pr` mode for manual releases + - Crates.io publishing support + - `actions/checkout@v6` (currently v4) + - `actions/cache@v5` (currently v4) +- **Action**: Migrate CI/CD to match template workflow. + +### R8: Clippy Lints Configuration +- **Reference**: trees-rs and template use `[lints.clippy]` in Cargo.toml with pedantic + nursery. +- **Action**: Add `[lints.rust]` and `[lints.clippy]` sections. + +### R9: Release Profile Optimization +- **Reference**: trees-rs and template use `lto = true`, `codegen-units = 1`, `strip = true`. +- **Action**: Add `[profile.release]` section. + +### R10: Package Metadata +- **Current**: Repository URL points to `platform-rs` (incorrect). +- **Action**: Fix repository URL to `data-rs`, add `keywords`, `categories`, `readme`, `rust-version`. + +## Solution Plan + +### Phase 1: Core Configuration Updates +1. Update `Cargo.toml` (edition, MSRV, deps, lints, profile, metadata) +2. Update `rust-toolchain.toml` and `rustfmt.toml` + +### Phase 2: Code Quality +3. Fix any edition 2024 compatibility issues in source code +4. Add/improve doc comments on all public items +5. Fix clippy warnings with new lint configuration + +### Phase 3: CI/CD Migration +6. Migrate scripts from Node.js (.mjs) to Rust (.rs) using rust-script +7. Upgrade workflow to match template (change detection, version-check, deploy-docs, codecov) + +### Phase 4: Testing and Documentation +8. Add tests for uncovered code paths +9. Update README.md and CONTRIBUTING.md +10. Add changelog fragment + +## Reference Components + +| Component | Source | Purpose | +|-----------|--------|---------| +| `rust-script` | crates.io | Run Rust scripts without Cargo project | +| `cargo-llvm-cov` | crates.io | LLVM-based code coverage | +| `peaceiris/actions-gh-pages@v4` | GitHub | Deploy to GitHub Pages | +| `peter-evans/create-pull-request@v8` | GitHub | Create changelog PRs | +| `codecov/codecov-action@v5` | GitHub | Upload coverage to Codecov | +| `dtolnay/rust-toolchain@stable` | GitHub | Rust toolchain setup | diff --git a/rustfmt.toml b/rustfmt.toml index 02a3e70..f216078 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,3 +1 @@ -# Stable rustfmt configuration -# For available options, see: https://rust-lang.github.io/rustfmt/ -edition = "2018" +edition = "2024" diff --git a/scripts/bump-version.mjs b/scripts/bump-version.mjs deleted file mode 100644 index 579aafc..0000000 --- a/scripts/bump-version.mjs +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env node - -/** - * Bump version in Cargo.toml - * Usage: node scripts/bump-version.mjs --bump-type [--dry-run] - * - * Uses link-foundation libraries: - * - use-m: Dynamic package loading without package.json dependencies - * - lino-arguments: Unified configuration from CLI args, env vars, and .lenv files - */ - -import { readFileSync, writeFileSync } from 'fs'; - -// Load use-m dynamically -const { use } = eval( - await (await fetch('https://unpkg.com/use-m/use.js')).text() -); - -// Import lino-arguments for CLI argument parsing -const { makeConfig } = await use('lino-arguments'); - -// Parse CLI arguments -const config = makeConfig({ - yargs: ({ yargs, getenv }) => - yargs - .option('bump-type', { - type: 'string', - default: getenv('BUMP_TYPE', ''), - describe: 'Version bump type: major, minor, or patch', - choices: ['major', 'minor', 'patch'], - }) - .option('dry-run', { - type: 'boolean', - default: false, - describe: 'Show what would be done without making changes', - }), -}); - -const { bumpType, dryRun } = config; - -if (!bumpType || !['major', 'minor', 'patch'].includes(bumpType)) { - console.error( - 'Usage: node scripts/bump-version.mjs --bump-type [--dry-run]' - ); - process.exit(1); -} - -/** - * Get current version from Cargo.toml - * @returns {{major: number, minor: number, patch: number}} - */ -function getCurrentVersion() { - const cargoToml = readFileSync('Cargo.toml', 'utf-8'); - const match = cargoToml.match(/^version\s*=\s*"(\d+)\.(\d+)\.(\d+)"/m); - - if (!match) { - console.error('Error: Could not parse version from Cargo.toml'); - process.exit(1); - } - - return { - major: parseInt(match[1], 10), - minor: parseInt(match[2], 10), - patch: parseInt(match[3], 10), - }; -} - -/** - * Calculate new version based on bump type - * @param {{major: number, minor: number, patch: number}} current - * @param {string} bumpType - * @returns {string} - */ -function calculateNewVersion(current, bumpType) { - const { major, minor, patch } = current; - - switch (bumpType) { - case 'major': - return `${major + 1}.0.0`; - case 'minor': - return `${major}.${minor + 1}.0`; - case 'patch': - return `${major}.${minor}.${patch + 1}`; - default: - throw new Error(`Invalid bump type: ${bumpType}`); - } -} - -/** - * Update version in Cargo.toml - * @param {string} newVersion - */ -function updateCargoToml(newVersion) { - let cargoToml = readFileSync('Cargo.toml', 'utf-8'); - cargoToml = cargoToml.replace( - /^(version\s*=\s*")[^"]+(")/m, - `$1${newVersion}$2` - ); - writeFileSync('Cargo.toml', cargoToml, 'utf-8'); -} - -try { - const current = getCurrentVersion(); - const currentStr = `${current.major}.${current.minor}.${current.patch}`; - const newVersion = calculateNewVersion(current, bumpType); - - console.log(`Current version: ${currentStr}`); - console.log(`New version: ${newVersion}`); - - if (dryRun) { - console.log('Dry run - no changes made'); - } else { - updateCargoToml(newVersion); - console.log('Updated Cargo.toml'); - } -} catch (error) { - console.error('Error:', error.message); - process.exit(1); -} diff --git a/scripts/bump-version.rs b/scripts/bump-version.rs new file mode 100644 index 0000000..78021f1 --- /dev/null +++ b/scripts/bump-version.rs @@ -0,0 +1,178 @@ +#!/usr/bin/env rust-script +//! Bump version in Cargo.toml +//! +//! Usage: rust-script scripts/bump-version.rs --bump-type [--dry-run] [--rust-root ] +//! +//! Supports both single-language and multi-language repository structures: +//! - Single-language: Cargo.toml in repository root +//! - Multi-language: Cargo.toml in rust/ subfolder +//! +//! ```cargo +//! [dependencies] +//! regex = "1" +//! ``` + +use std::env; +use std::fs; +use std::path::Path; +use std::process::exit; +use regex::Regex; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum BumpType { + Major, + Minor, + Patch, +} + +impl BumpType { + fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "major" => Some(BumpType::Major), + "minor" => Some(BumpType::Minor), + "patch" => Some(BumpType::Patch), + _ => None, + } + } +} + +struct Version { + major: u32, + minor: u32, + patch: u32, +} + +impl Version { + fn bump(&self, bump_type: BumpType) -> String { + match bump_type { + BumpType::Major => format!("{}.0.0", self.major + 1), + BumpType::Minor => format!("{}.{}.0", self.major, self.minor + 1), + BumpType::Patch => format!("{}.{}.{}", self.major, self.minor, self.patch + 1), + } + } + + fn to_string(&self) -> String { + format!("{}.{}.{}", self.major, self.minor, self.patch) + } +} + +fn get_arg(name: &str) -> Option { + let args: Vec = env::args().collect(); + let flag = format!("--{}", name); + + if let Some(idx) = args.iter().position(|a| a == &flag) { + return args.get(idx + 1).cloned(); + } + + // Check environment variable (convert dashes to underscores) + let env_name = name.to_uppercase().replace('-', "_"); + env::var(&env_name).ok().filter(|s| !s.is_empty()) +} + +fn has_flag(name: &str) -> bool { + let args: Vec = env::args().collect(); + let flag = format!("--{}", name); + args.contains(&flag) +} + +fn get_rust_root() -> String { + if let Some(root) = get_arg("rust-root") { + return root; + } + + // Auto-detect + if Path::new("./Cargo.toml").exists() { + eprintln!("Detected single-language repository (Cargo.toml in root)"); + return ".".to_string(); + } + + if Path::new("./rust/Cargo.toml").exists() { + eprintln!("Detected multi-language repository (Cargo.toml in rust/)"); + return "rust".to_string(); + } + + eprintln!("Error: Could not find Cargo.toml in expected locations"); + exit(1); +} + +fn get_cargo_toml_path(rust_root: &str) -> String { + if rust_root == "." { + "./Cargo.toml".to_string() + } else { + format!("{}/Cargo.toml", rust_root) + } +} + +fn get_current_version(cargo_toml_path: &str) -> Result { + let content = fs::read_to_string(cargo_toml_path) + .map_err(|e| format!("Failed to read {}: {}", cargo_toml_path, e))?; + + let re = Regex::new(r#"(?m)^version\s*=\s*"(\d+)\.(\d+)\.(\d+)""#).unwrap(); + + if let Some(caps) = re.captures(&content) { + let major: u32 = caps.get(1).unwrap().as_str().parse().unwrap(); + let minor: u32 = caps.get(2).unwrap().as_str().parse().unwrap(); + let patch: u32 = caps.get(3).unwrap().as_str().parse().unwrap(); + Ok(Version { major, minor, patch }) + } else { + Err(format!("Could not parse version from {}", cargo_toml_path)) + } +} + +fn update_cargo_toml(cargo_toml_path: &str, new_version: &str) -> Result<(), String> { + let content = fs::read_to_string(cargo_toml_path) + .map_err(|e| format!("Failed to read {}: {}", cargo_toml_path, e))?; + + let re = Regex::new(r#"(?m)^(version\s*=\s*")[^"]+(")"#).unwrap(); + let new_content = re.replace(&content, format!("${{1}}{}${{2}}", new_version).as_str()); + + fs::write(cargo_toml_path, new_content.as_ref()) + .map_err(|e| format!("Failed to write {}: {}", cargo_toml_path, e))?; + + Ok(()) +} + +fn main() { + let bump_type_str = match get_arg("bump-type") { + Some(s) => s, + None => { + eprintln!("Usage: rust-script scripts/bump-version.rs --bump-type [--dry-run] [--rust-root ]"); + exit(1); + } + }; + + let bump_type = match BumpType::from_str(&bump_type_str) { + Some(bt) => bt, + None => { + eprintln!("Invalid bump type: {}. Must be major, minor, or patch.", bump_type_str); + exit(1); + } + }; + + let dry_run = has_flag("dry-run"); + let rust_root = get_rust_root(); + let cargo_toml = get_cargo_toml_path(&rust_root); + + let current = match get_current_version(&cargo_toml) { + Ok(v) => v, + Err(e) => { + eprintln!("Error: {}", e); + exit(1); + } + }; + + let new_version = current.bump(bump_type); + + println!("Current version: {}", current.to_string()); + println!("New version: {}", new_version); + + if dry_run { + println!("Dry run - no changes made"); + } else { + if let Err(e) = update_cargo_toml(&cargo_toml, &new_version) { + eprintln!("Error: {}", e); + exit(1); + } + println!("Updated {}", cargo_toml); + } +} diff --git a/scripts/check-changelog-fragment.rs b/scripts/check-changelog-fragment.rs new file mode 100644 index 0000000..70faf74 --- /dev/null +++ b/scripts/check-changelog-fragment.rs @@ -0,0 +1,164 @@ +#!/usr/bin/env rust-script +//! Check if a changelog fragment was added in the current PR +//! +//! This script validates that a changelog fragment is added in the PR diff, +//! not just checking if any fragments exist in the directory. This prevents +//! the check from incorrectly passing when there are leftover fragments +//! from previous PRs that haven't been released yet. +//! +//! Usage: rust-script scripts/check-changelog-fragment.rs +//! +//! Environment variables (set by GitHub Actions): +//! - GITHUB_BASE_REF: Base branch name for PR (e.g., "main") +//! +//! Exit codes: +//! - 0: Check passed (fragment added or no source changes) +//! - 1: Check failed (source changes without changelog fragment) +//! +//! ```cargo +//! [dependencies] +//! regex = "1" +//! ``` + +use std::env; +use std::path::Path; +use std::process::{Command, exit}; +use regex::Regex; + +fn exec(command: &str, args: &[&str]) -> String { + match Command::new(command).args(args).output() { + Ok(output) => { + if output.status.success() { + String::from_utf8_lossy(&output.stdout).trim().to_string() + } else { + eprintln!("Error executing {} {:?}", command, args); + eprintln!("{}", String::from_utf8_lossy(&output.stderr)); + String::new() + } + } + Err(e) => { + eprintln!("Failed to execute {} {:?}: {}", command, args, e); + String::new() + } + } +} + +fn get_rust_root() -> String { + if let Ok(root) = env::var("RUST_ROOT") { + if !root.is_empty() { + return root; + } + } + + if Path::new("./Cargo.toml").exists() { + return ".".to_string(); + } + + if Path::new("./rust/Cargo.toml").exists() { + return "rust".to_string(); + } + + ".".to_string() +} + +fn get_changed_files() -> Vec { + let base_ref = env::var("GITHUB_BASE_REF").unwrap_or_else(|_| "main".to_string()); + eprintln!("Comparing against origin/{}...HEAD", base_ref); + + let output = exec( + "git", + &["diff", "--name-only", &format!("origin/{}...HEAD", base_ref)], + ); + + if output.is_empty() { + return Vec::new(); + } + + output.lines().filter(|s| !s.is_empty()).map(String::from).collect() +} + +fn is_source_file(file_path: &str, rust_root: &str) -> bool { + let prefix = if rust_root == "." { String::new() } else { format!("{}/", rust_root) }; + + let source_patterns = [ + Regex::new(&format!(r"^{}src/", regex::escape(&prefix))).unwrap(), + Regex::new(&format!(r"^{}tests/", regex::escape(&prefix))).unwrap(), + Regex::new(&format!(r"^{}?scripts/", regex::escape(&prefix))).unwrap(), + Regex::new(&format!(r"^{}Cargo\.toml$", regex::escape(&prefix))).unwrap(), + ]; + + source_patterns.iter().any(|pattern| pattern.is_match(file_path)) +} + +fn is_changelog_fragment(file_path: &str, rust_root: &str) -> bool { + let changelog_dir = if rust_root == "." { "changelog.d/".to_string() } else { format!("{}/changelog.d/", rust_root) }; + + (file_path.starts_with(&changelog_dir) || file_path.starts_with("changelog.d/")) + && file_path.ends_with(".md") + && !file_path.ends_with("README.md") +} + +fn main() { + println!("Checking for changelog fragment in PR diff...\n"); + + let rust_root = get_rust_root(); + if rust_root != "." { + println!("Detected multi-language repository (Rust root: {})", rust_root); + } + + let changed_files = get_changed_files(); + + if changed_files.is_empty() { + println!("No changed files found"); + exit(0); + } + + println!("Changed files:"); + for file in &changed_files { + println!(" {}", file); + } + println!(); + + // Count source files changed + let source_changes: Vec<&String> = changed_files.iter().filter(|f| is_source_file(f, &rust_root)).collect(); + let source_changed_count = source_changes.len(); + + println!("Source files changed: {}", source_changed_count); + if source_changed_count > 0 { + for file in &source_changes { + println!(" {}", file); + } + } + println!(); + + // Count changelog fragments added in this PR + let fragments_added: Vec<&String> = changed_files + .iter() + .filter(|f| is_changelog_fragment(f, &rust_root)) + .collect(); + let fragment_added_count = fragments_added.len(); + + println!("Changelog fragments added: {}", fragment_added_count); + if fragment_added_count > 0 { + for file in &fragments_added { + println!(" {}", file); + } + } + println!(); + + // Check if source files changed but no fragment was added + if source_changed_count > 0 && fragment_added_count == 0 { + eprintln!("::error::No changelog fragment found in this PR. Please add a changelog entry in changelog.d/"); + eprintln!(); + eprintln!("To create a changelog fragment:"); + eprintln!(" Create a new .md file in changelog.d/ with your changes"); + eprintln!(); + eprintln!("See changelog.d/README.md for more information."); + exit(1); + } + + println!( + "Changelog check passed (source files changed: {}, fragments added: {})", + source_changed_count, fragment_added_count + ); +} diff --git a/scripts/check-file-size.mjs b/scripts/check-file-size.mjs deleted file mode 100644 index 4f2aedc..0000000 --- a/scripts/check-file-size.mjs +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env node - -/** - * Check for files exceeding the maximum allowed line count - * Exits with error code 1 if any files exceed the limit - * - * Uses link-foundation libraries: - * - use-m: Dynamic package loading without package.json dependencies - */ - -import { readFileSync, readdirSync, statSync } from 'fs'; -import { join, relative, extname } from 'path'; - -const MAX_LINES = 1000; -const FILE_EXTENSIONS = ['.rs']; -const EXCLUDE_PATTERNS = ['target', '.git', 'node_modules']; - -/** - * Check if a path should be excluded - * @param {string} path - * @returns {boolean} - */ -function shouldExclude(path) { - return EXCLUDE_PATTERNS.some((pattern) => path.includes(pattern)); -} - -/** - * Recursively find all Rust files in a directory - * @param {string} directory - * @returns {string[]} - */ -function findRustFiles(directory) { - const files = []; - - function walkDir(dir) { - const entries = readdirSync(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = join(dir, entry.name); - - if (shouldExclude(fullPath)) { - continue; - } - - if (entry.isDirectory()) { - walkDir(fullPath); - } else if (entry.isFile() && FILE_EXTENSIONS.includes(extname(entry.name))) { - files.push(fullPath); - } - } - } - - walkDir(directory); - return files; -} - -/** - * Count lines in a file - * @param {string} filePath - * @returns {number} - */ -function countLines(filePath) { - const content = readFileSync(filePath, 'utf-8'); - return content.split('\n').length; -} - -try { - const cwd = process.cwd(); - console.log(`\nChecking Rust files for maximum ${MAX_LINES} lines...\n`); - - const files = findRustFiles(cwd); - const violations = []; - - for (const file of files) { - const lineCount = countLines(file); - if (lineCount > MAX_LINES) { - violations.push({ - file: relative(cwd, file), - lines: lineCount, - }); - } - } - - if (violations.length === 0) { - console.log('All files are within the line limit\n'); - process.exit(0); - } else { - console.log('Found files exceeding the line limit:\n'); - for (const violation of violations) { - console.log( - ` ${violation.file}: ${violation.lines} lines (exceeds ${MAX_LINES})` - ); - } - console.log(`\nPlease refactor these files to be under ${MAX_LINES} lines\n`); - process.exit(1); - } -} catch (error) { - console.error('Error:', error.message); - process.exit(1); -} diff --git a/scripts/check-file-size.rs b/scripts/check-file-size.rs new file mode 100644 index 0000000..f5eb668 --- /dev/null +++ b/scripts/check-file-size.rs @@ -0,0 +1,100 @@ +#!/usr/bin/env rust-script +//! Check for files exceeding the maximum allowed line count +//! Exits with error code 1 if any files exceed the limit +//! +//! Usage: rust-script scripts/check-file-size.rs +//! +//! ```cargo +//! [dependencies] +//! walkdir = "2" +//! ``` + +use std::fs; +use std::path::Path; +use std::process::exit; +use walkdir::WalkDir; + +const MAX_LINES: usize = 1000; +const FILE_EXTENSIONS: &[&str] = &[".rs"]; +const EXCLUDE_PATTERNS: &[&str] = &["target", ".git", "node_modules"]; + +fn should_exclude(path: &Path) -> bool { + let path_str = path.to_string_lossy(); + EXCLUDE_PATTERNS.iter().any(|pattern| path_str.contains(pattern)) +} + +fn has_valid_extension(path: &Path) -> bool { + if let Some(ext) = path.extension() { + let ext_with_dot = format!(".{}", ext.to_string_lossy()); + FILE_EXTENSIONS.contains(&ext_with_dot.as_str()) + } else { + false + } +} + +fn count_lines(path: &Path) -> Result { + let content = fs::read_to_string(path)?; + Ok(content.lines().count()) +} + +struct Violation { + file: String, + lines: usize, +} + +fn main() { + println!("\nChecking Rust files for maximum {} lines...\n", MAX_LINES); + + let cwd = std::env::current_dir().expect("Failed to get current directory"); + let mut violations: Vec = Vec::new(); + + for entry in WalkDir::new(&cwd) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + { + let path = entry.path(); + + if should_exclude(path) { + continue; + } + + if !has_valid_extension(path) { + continue; + } + + match count_lines(path) { + Ok(line_count) => { + if line_count > MAX_LINES { + let relative_path = path + .strip_prefix(&cwd) + .unwrap_or(path) + .to_string_lossy() + .to_string(); + violations.push(Violation { + file: relative_path, + lines: line_count, + }); + } + } + Err(e) => { + eprintln!("Warning: Could not read {}: {}", path.display(), e); + } + } + } + + if violations.is_empty() { + println!("All files are within the line limit\n"); + exit(0); + } else { + println!("Found files exceeding the line limit:\n"); + for violation in &violations { + println!( + " {}: {} lines (exceeds {})", + violation.file, violation.lines, MAX_LINES + ); + } + println!("\nPlease refactor these files to be under {} lines\n", MAX_LINES); + exit(1); + } +} diff --git a/scripts/check-release-needed.rs b/scripts/check-release-needed.rs new file mode 100644 index 0000000..d7f46a3 --- /dev/null +++ b/scripts/check-release-needed.rs @@ -0,0 +1,292 @@ +#!/usr/bin/env rust-script +//! Check if a release is needed based on changelog fragments and version state +//! +//! This script checks: +//! 1. If there are changelog fragments to process +//! 2. If the current version has already been published to crates.io +//! +//! IMPORTANT: This script checks crates.io (the source of truth for Rust packages), +//! NOT git tags. This is critical because: +//! - Git tags can exist without the package being published +//! - GitHub releases create tags but don't publish to crates.io +//! - Only crates.io publication means users can actually install the package +//! +//! Supports both single-language and multi-language repository structures: +//! - Single-language: Cargo.toml in repository root +//! - Multi-language: Cargo.toml in rust/ subfolder +//! +//! Usage: rust-script scripts/check-release-needed.rs [--rust-root ] +//! +//! Environment variables: +//! - HAS_FRAGMENTS: 'true' if changelog fragments exist (from get-bump-type.rs) +//! +//! Outputs (written to GITHUB_OUTPUT): +//! - should_release: 'true' if a release should be created +//! - skip_bump: 'true' if version bump should be skipped (version not yet released) +//! - max_published_version: the highest non-yanked version on crates.io (for downstream use) +//! +//! ```cargo +//! [dependencies] +//! regex = "1" +//! ureq = "2" +//! serde = { version = "1", features = ["derive"] } +//! serde_json = "1" +//! ``` + +use std::env; +use std::fs; +use std::path::Path; +use std::process::exit; +use regex::Regex; +use serde::Deserialize; + +fn get_arg(name: &str) -> Option { + let args: Vec = env::args().collect(); + let flag = format!("--{}", name); + + if let Some(idx) = args.iter().position(|a| a == &flag) { + return args.get(idx + 1).cloned(); + } + + let env_name = name.to_uppercase().replace('-', "_"); + env::var(&env_name).ok().filter(|s| !s.is_empty()) +} + +fn get_rust_root() -> String { + if let Some(root) = get_arg("rust-root") { + eprintln!("Using explicitly configured Rust root: {}", root); + return root; + } + + if Path::new("./Cargo.toml").exists() { + eprintln!("Detected single-language repository (Cargo.toml in root)"); + return ".".to_string(); + } + + if Path::new("./rust/Cargo.toml").exists() { + eprintln!("Detected multi-language repository (Cargo.toml in rust/)"); + return "rust".to_string(); + } + + eprintln!("Error: Could not find Cargo.toml in expected locations"); + exit(1); +} + +fn get_cargo_toml_path(rust_root: &str) -> String { + if rust_root == "." { + "./Cargo.toml".to_string() + } else { + format!("{}/Cargo.toml", rust_root) + } +} + +fn set_output(key: &str, value: &str) { + if let Ok(output_file) = env::var("GITHUB_OUTPUT") { + if let Err(e) = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&output_file) + .and_then(|mut f| { + use std::io::Write; + writeln!(f, "{}={}", key, value) + }) + { + eprintln!("Warning: Could not write to GITHUB_OUTPUT: {}", e); + } + } + println!("Output: {}={}", key, value); +} + +fn get_current_version(cargo_toml_path: &str) -> Result { + let content = fs::read_to_string(cargo_toml_path) + .map_err(|e| format!("Failed to read {}: {}", cargo_toml_path, e))?; + + let re = Regex::new(r#"(?m)^version\s*=\s*"([^"]+)""#).unwrap(); + + if let Some(caps) = re.captures(&content) { + Ok(caps.get(1).unwrap().as_str().to_string()) + } else { + Err(format!("Could not find version in {}", cargo_toml_path)) + } +} + +fn get_crate_name(cargo_toml_path: &str) -> Result { + let content = fs::read_to_string(cargo_toml_path) + .map_err(|e| format!("Failed to read {}: {}", cargo_toml_path, e))?; + + let re = Regex::new(r#"(?m)^name\s*=\s*"([^"]+)""#).unwrap(); + + if let Some(caps) = re.captures(&content) { + Ok(caps.get(1).unwrap().as_str().to_string()) + } else { + Err(format!("Could not find name in {}", cargo_toml_path)) + } +} + +#[derive(Deserialize)] +struct CratesIoVersion { + version: Option, +} + +#[derive(Deserialize)] +struct CratesIoVersionInfo { + #[allow(dead_code)] + num: String, +} + +#[derive(Deserialize)] +struct CratesIoCrate { + versions: Option>, +} + +#[derive(Deserialize)] +struct CratesIoVersionEntry { + num: String, + yanked: bool, +} + +fn check_version_on_crates_io(crate_name: &str, version: &str) -> bool { + let url = format!("https://crates.io/api/v1/crates/{}/{}", crate_name, version); + + match ureq::get(&url) + .set("User-Agent", "rust-script-check-release") + .call() + { + Ok(response) => { + if response.status() == 200 { + if let Ok(body) = response.into_string() { + if let Ok(data) = serde_json::from_str::(&body) { + return data.version.is_some(); + } + } + } + false + } + Err(ureq::Error::Status(404, _)) => { + false + } + Err(e) => { + eprintln!("Warning: Could not check crates.io: {}", e); + false + } + } +} + +fn parse_semver(version: &str) -> Option<(u32, u32, u32)> { + let parts: Vec<&str> = version.split('-').next()?.split('.').collect(); + if parts.len() != 3 { + return None; + } + Some(( + parts[0].parse().ok()?, + parts[1].parse().ok()?, + parts[2].parse().ok()?, + )) +} + +fn get_max_published_version(crate_name: &str) -> Option { + let url = format!("https://crates.io/api/v1/crates/{}", crate_name); + + match ureq::get(&url) + .set("User-Agent", "rust-script-check-release") + .call() + { + Ok(response) => { + if response.status() == 200 { + if let Ok(body) = response.into_string() { + if let Ok(data) = serde_json::from_str::(&body) { + if let Some(versions) = data.versions { + let mut max_version: Option<(u32, u32, u32, String)> = None; + for v in &versions { + if v.yanked { + continue; + } + if let Some(parsed) = parse_semver(&v.num) { + match &max_version { + None => { + max_version = Some((parsed.0, parsed.1, parsed.2, v.num.clone())); + } + Some(current) => { + if parsed > (current.0, current.1, current.2) { + max_version = Some((parsed.0, parsed.1, parsed.2, v.num.clone())); + } + } + } + } + } + return max_version.map(|v| v.3); + } + } + } + } + None + } + Err(ureq::Error::Status(404, _)) => None, + Err(e) => { + eprintln!("Warning: Could not query crates.io for versions: {}", e); + None + } + } +} + +fn main() { + let rust_root = get_rust_root(); + let cargo_toml = get_cargo_toml_path(&rust_root); + + let has_fragments = env::var("HAS_FRAGMENTS") + .map(|v| v == "true") + .unwrap_or(false); + + let crate_name = match get_crate_name(&cargo_toml) { + Ok(name) => name, + Err(e) => { + eprintln!("Error: {}", e); + exit(1); + } + }; + + let current_version = match get_current_version(&cargo_toml) { + Ok(version) => version, + Err(e) => { + eprintln!("Error: {}", e); + exit(1); + } + }; + + let max_published = get_max_published_version(&crate_name); + if let Some(ref max_ver) = max_published { + println!("Max published version on crates.io: {}", max_ver); + set_output("max_published_version", max_ver); + } else { + println!("No versions published on crates.io yet (or crate not found)"); + set_output("max_published_version", ""); + } + + if !has_fragments { + let is_published = check_version_on_crates_io(&crate_name, ¤t_version); + + println!( + "Crate: {}, Version: {}, Published on crates.io: {}", + crate_name, current_version, is_published + ); + + if is_published { + println!( + "No changelog fragments and v{} already published on crates.io", + current_version + ); + set_output("should_release", "false"); + } else { + println!( + "No changelog fragments but v{} not yet published to crates.io", + current_version + ); + set_output("should_release", "true"); + set_output("skip_bump", "true"); + } + } else { + println!("Found changelog fragments, proceeding with release"); + set_output("should_release", "true"); + set_output("skip_bump", "false"); + } +} diff --git a/scripts/check-version-modification.rs b/scripts/check-version-modification.rs new file mode 100644 index 0000000..09ebb33 --- /dev/null +++ b/scripts/check-version-modification.rs @@ -0,0 +1,163 @@ +#!/usr/bin/env rust-script +//! Check for manual version modification in Cargo.toml +//! +//! This script prevents manual version changes in pull requests. +//! Versions should be managed automatically by the CI/CD pipeline +//! using changelog fragments in changelog.d/. +//! +//! Key behavior: +//! - Detects if `version = "..."` line has changed in Cargo.toml +//! - Fails the CI check if manual version change is detected +//! - Skips check for automated release branches (changelog-manual-release-*) +//! +//! Usage: rust-script scripts/check-version-modification.rs +//! +//! Environment variables (set by GitHub Actions): +//! - GITHUB_HEAD_REF: The head branch name for PRs +//! - GITHUB_BASE_REF: The base branch name for PRs +//! - GITHUB_EVENT_NAME: Should be 'pull_request' +//! +//! Exit codes: +//! - 0: No manual version changes detected (or check skipped) +//! - 1: Manual version changes detected +//! +//! ```cargo +//! [dependencies] +//! regex = "1" +//! ``` + +use std::env; +use std::path::Path; +use std::process::{Command, exit}; +use regex::Regex; + +fn exec(command: &str, args: &[&str]) -> String { + match Command::new(command).args(args).output() { + Ok(output) => { + String::from_utf8_lossy(&output.stdout).trim().to_string() + } + Err(_) => String::new(), + } +} + +fn exec_ignore_error(command: &str, args: &[&str]) { + let _ = Command::new(command) + .args(args) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); +} + +fn should_skip_version_check() -> bool { + let head_ref = env::var("GITHUB_HEAD_REF").unwrap_or_default(); + + // Skip for automated release PRs + let automated_branch_prefixes = [ + "changelog-manual-release-", + "changeset-release/", + "release/", + "automated-release/", + ]; + + for prefix in &automated_branch_prefixes { + if head_ref.starts_with(prefix) { + println!("Skipping version check for automated branch: {}", head_ref); + return true; + } + } + + false +} + +fn get_rust_root() -> String { + if let Ok(root) = env::var("RUST_ROOT") { + if !root.is_empty() { + return root; + } + } + + if Path::new("./Cargo.toml").exists() { + return ".".to_string(); + } + + if Path::new("./rust/Cargo.toml").exists() { + return "rust".to_string(); + } + + ".".to_string() +} + +fn get_cargo_toml_path(rust_root: &str) -> String { + if rust_root == "." { + "Cargo.toml".to_string() + } else { + format!("{}/Cargo.toml", rust_root) + } +} + +fn get_cargo_toml_diff(cargo_toml_path: &str) -> String { + let base_ref = env::var("GITHUB_BASE_REF").unwrap_or_else(|_| "main".to_string()); + + // Ensure we have the base branch + exec_ignore_error("git", &["fetch", "origin", &base_ref, "--depth=1"]); + + // Get the diff for Cargo.toml + exec( + "git", + &["diff", &format!("origin/{}...HEAD", base_ref), "--", cargo_toml_path], + ) +} + +fn has_version_change(diff: &str) -> bool { + if diff.is_empty() { + return false; + } + + // Look for changes to the version line + // Match lines that start with + or - followed by version = "..." + let version_change_pattern = Regex::new(r#"(?m)^[+-]version\s*=\s*""#).unwrap(); + version_change_pattern.is_match(diff) +} + +fn main() { + println!("Checking for manual version modifications in Cargo.toml...\n"); + + // Only run on pull requests + let event_name = env::var("GITHUB_EVENT_NAME").unwrap_or_default(); + if event_name != "pull_request" { + println!("Skipping: Not a pull request event (event: {})", event_name); + exit(0); + } + + // Skip for automated release branches + if should_skip_version_check() { + exit(0); + } + + // Get and check the diff + let rust_root = get_rust_root(); + let cargo_toml_path = get_cargo_toml_path(&rust_root); + let diff = get_cargo_toml_diff(&cargo_toml_path); + + if diff.is_empty() { + println!("No changes to Cargo.toml detected."); + println!("Version check passed."); + exit(0); + } + + // Check for version changes + if has_version_change(&diff) { + eprintln!("Error: Manual version change detected in Cargo.toml!\n"); + eprintln!("Versions are managed automatically by the CI/CD pipeline."); + eprintln!("Please do not modify the version field directly.\n"); + eprintln!("To trigger a release, add a changelog fragment to changelog.d/"); + eprintln!("with the appropriate bump type (major, minor, or patch).\n"); + eprintln!("See changelog.d/README.md for more information.\n"); + eprintln!("If you need to undo your version change, run:"); + eprintln!(" git checkout origin/main -- Cargo.toml"); + exit(1); + } + + println!("Cargo.toml was modified but version field was not changed."); + println!("Version check passed."); +} diff --git a/scripts/collect-changelog.mjs b/scripts/collect-changelog.mjs deleted file mode 100644 index a8c59ac..0000000 --- a/scripts/collect-changelog.mjs +++ /dev/null @@ -1,170 +0,0 @@ -#!/usr/bin/env node - -/** - * Collect changelog fragments into CHANGELOG.md - * This script collects all .md files from changelog.d/ (except README.md) - * and prepends them to CHANGELOG.md, then removes the processed fragments. - * - * Uses link-foundation libraries: - * - use-m: Dynamic package loading without package.json dependencies - */ - -import { - readFileSync, - writeFileSync, - readdirSync, - unlinkSync, - existsSync, -} from 'fs'; -import { join } from 'path'; - -const CHANGELOG_DIR = 'changelog.d'; -const CHANGELOG_FILE = 'CHANGELOG.md'; -const INSERT_MARKER = ''; - -/** - * Get version from Cargo.toml - * @returns {string} - */ -function getVersionFromCargo() { - const cargoToml = readFileSync('Cargo.toml', 'utf-8'); - const match = cargoToml.match(/^version\s*=\s*"([^"]+)"/m); - - if (!match) { - console.error('Error: Could not find version in Cargo.toml'); - process.exit(1); - } - - return match[1]; -} - -/** - * Strip frontmatter from markdown content - * @param {string} content - Markdown content potentially with frontmatter - * @returns {string} - Content without frontmatter - */ -function stripFrontmatter(content) { - const frontmatterMatch = content.match(/^---\s*\n[\s\S]*?\n---\s*\n([\s\S]*)$/); - if (frontmatterMatch) { - return frontmatterMatch[1].trim(); - } - return content.trim(); -} - -/** - * Collect all changelog fragments - * @returns {string} - */ -function collectFragments() { - if (!existsSync(CHANGELOG_DIR)) { - return ''; - } - - const files = readdirSync(CHANGELOG_DIR) - .filter((f) => f.endsWith('.md') && f !== 'README.md') - .sort(); - - const fragments = []; - for (const file of files) { - const rawContent = readFileSync(join(CHANGELOG_DIR, file), 'utf-8'); - // Strip frontmatter (which contains bump type metadata) - const content = stripFrontmatter(rawContent); - if (content) { - fragments.push(content); - } - } - - return fragments.join('\n\n'); -} - -/** - * Update CHANGELOG.md with collected fragments - * @param {string} version - * @param {string} fragments - */ -function updateChangelog(version, fragments) { - const dateStr = new Date().toISOString().split('T')[0]; - const newEntry = `\n## [${version}] - ${dateStr}\n\n${fragments}\n`; - - if (existsSync(CHANGELOG_FILE)) { - let content = readFileSync(CHANGELOG_FILE, 'utf-8'); - - if (content.includes(INSERT_MARKER)) { - content = content.replace(INSERT_MARKER, `${INSERT_MARKER}${newEntry}`); - } else { - // Insert after the first ## heading - const lines = content.split('\n'); - let insertIndex = -1; - - for (let i = 0; i < lines.length; i++) { - if (lines[i].startsWith('## [')) { - insertIndex = i; - break; - } - } - - if (insertIndex >= 0) { - lines.splice(insertIndex, 0, newEntry); - content = lines.join('\n'); - } else { - // Append after the main heading - content += newEntry; - } - } - - writeFileSync(CHANGELOG_FILE, content, 'utf-8'); - } else { - const content = `# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -${INSERT_MARKER} -${newEntry} -`; - writeFileSync(CHANGELOG_FILE, content, 'utf-8'); - } - - console.log(`Updated CHANGELOG.md with version ${version}`); -} - -/** - * Remove processed changelog fragments - */ -function removeFragments() { - if (!existsSync(CHANGELOG_DIR)) { - return; - } - - const files = readdirSync(CHANGELOG_DIR).filter( - (f) => f.endsWith('.md') && f !== 'README.md' - ); - - for (const file of files) { - const filePath = join(CHANGELOG_DIR, file); - unlinkSync(filePath); - console.log(`Removed ${filePath}`); - } -} - -try { - const version = getVersionFromCargo(); - console.log(`Collecting changelog fragments for version ${version}`); - - const fragments = collectFragments(); - - if (!fragments) { - console.log('No changelog fragments found'); - process.exit(0); - } - - updateChangelog(version, fragments); - removeFragments(); - - console.log('Changelog collection complete'); -} catch (error) { - console.error('Error:', error.message); - process.exit(1); -} diff --git a/scripts/collect-changelog.rs b/scripts/collect-changelog.rs new file mode 100644 index 0000000..63b7d8c --- /dev/null +++ b/scripts/collect-changelog.rs @@ -0,0 +1,234 @@ +#!/usr/bin/env rust-script +//! Collect changelog fragments into CHANGELOG.md +//! +//! This script collects all .md files from changelog.d/ (except README.md) +//! and prepends them to CHANGELOG.md, then removes the processed fragments. +//! +//! Supports both single-language and multi-language repository structures: +//! - Single-language: Cargo.toml and changelog.d/ in repository root +//! - Multi-language: Cargo.toml and changelog.d/ in rust/ subfolder +//! +//! Usage: rust-script scripts/collect-changelog.rs [--rust-root ] +//! +//! ```cargo +//! [dependencies] +//! regex = "1" +//! chrono = "0.4" +//! ``` + +use std::env; +use std::fs; +use std::path::Path; +use std::process::exit; +use chrono::Utc; +use regex::Regex; + +const INSERT_MARKER: &str = ""; + +fn get_arg(name: &str) -> Option { + let args: Vec = env::args().collect(); + let flag = format!("--{}", name); + + if let Some(idx) = args.iter().position(|a| a == &flag) { + return args.get(idx + 1).cloned(); + } + + let env_name = name.to_uppercase().replace('-', "_"); + env::var(&env_name).ok().filter(|s| !s.is_empty()) +} + +fn get_rust_root() -> String { + if let Some(root) = get_arg("rust-root") { + eprintln!("Using explicitly configured Rust root: {}", root); + return root; + } + + if Path::new("./Cargo.toml").exists() { + eprintln!("Detected single-language repository (Cargo.toml in root)"); + return ".".to_string(); + } + + if Path::new("./rust/Cargo.toml").exists() { + eprintln!("Detected multi-language repository (Cargo.toml in rust/)"); + return "rust".to_string(); + } + + eprintln!("Error: Could not find Cargo.toml in expected locations"); + exit(1); +} + +fn get_cargo_toml_path(rust_root: &str) -> String { + if rust_root == "." { + "./Cargo.toml".to_string() + } else { + format!("{}/Cargo.toml", rust_root) + } +} + +fn get_changelog_dir(rust_root: &str) -> String { + if rust_root == "." { + "./changelog.d".to_string() + } else { + format!("{}/changelog.d", rust_root) + } +} + +fn get_changelog_path(rust_root: &str) -> String { + if rust_root == "." { + "./CHANGELOG.md".to_string() + } else { + format!("{}/CHANGELOG.md", rust_root) + } +} + +fn get_version_from_cargo(cargo_toml_path: &str) -> Result { + let content = fs::read_to_string(cargo_toml_path) + .map_err(|e| format!("Failed to read {}: {}", cargo_toml_path, e))?; + + let re = Regex::new(r#"(?m)^version\s*=\s*"([^"]+)""#).unwrap(); + + if let Some(caps) = re.captures(&content) { + Ok(caps.get(1).unwrap().as_str().to_string()) + } else { + Err(format!("Could not find version in {}", cargo_toml_path)) + } +} + +fn strip_frontmatter(content: &str) -> String { + let re = Regex::new(r"(?s)^---\s*\n.*?\n---\s*\n(.*)$").unwrap(); + if let Some(caps) = re.captures(content) { + caps.get(1).unwrap().as_str().trim().to_string() + } else { + content.trim().to_string() + } +} + +fn collect_fragments(changelog_dir: &str) -> String { + let dir_path = Path::new(changelog_dir); + if !dir_path.exists() { + return String::new(); + } + + let mut files: Vec<_> = match fs::read_dir(dir_path) { + Ok(entries) => entries + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| { + p.extension().map_or(false, |ext| ext == "md") + && p.file_name().map_or(false, |name| name != "README.md") + }) + .collect(), + Err(_) => return String::new(), + }; + + files.sort(); + + let mut fragments = Vec::new(); + for file in &files { + if let Ok(raw_content) = fs::read_to_string(file) { + let content = strip_frontmatter(&raw_content); + if !content.is_empty() { + fragments.push(content); + } + } + } + + fragments.join("\n\n") +} + +fn update_changelog(changelog_file: &str, version: &str, fragments: &str) { + let date_str = Utc::now().format("%Y-%m-%d").to_string(); + let new_entry = format!("\n## [{}] - {}\n\n{}\n", version, date_str, fragments); + + if Path::new(changelog_file).exists() { + let mut content = fs::read_to_string(changelog_file).unwrap_or_default(); + + if content.contains(INSERT_MARKER) { + content = content.replace(INSERT_MARKER, &format!("{}{}", INSERT_MARKER, new_entry)); + } else { + // Insert after the first ## heading + let lines: Vec<&str> = content.lines().collect(); + let mut insert_index = None; + + for (i, line) in lines.iter().enumerate() { + if line.starts_with("## [") { + insert_index = Some(i); + break; + } + } + + if let Some(idx) = insert_index { + let mut new_lines: Vec = lines[..idx].iter().map(|s| s.to_string()).collect(); + new_lines.push(new_entry.clone()); + new_lines.extend(lines[idx..].iter().map(|s| s.to_string())); + content = new_lines.join("\n"); + } else { + // Append after the main heading + content.push_str(&new_entry); + } + } + + fs::write(changelog_file, content).expect("Failed to write changelog"); + } else { + let content = format!( + "# Changelog\n\n\ + All notable changes to this project will be documented in this file.\n\n\ + The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\n\ + and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n\ + {}\n{}\n", + INSERT_MARKER, new_entry + ); + fs::write(changelog_file, content).expect("Failed to write changelog"); + } + + println!("Updated CHANGELOG.md with version {}", version); +} + +fn remove_fragments(changelog_dir: &str) { + let dir_path = Path::new(changelog_dir); + if !dir_path.exists() { + return; + } + + if let Ok(entries) = fs::read_dir(dir_path) { + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if path.extension().map_or(false, |ext| ext == "md") + && path.file_name().map_or(false, |name| name != "README.md") + { + if fs::remove_file(&path).is_ok() { + println!("Removed {}", path.display()); + } + } + } + } +} + +fn main() { + let rust_root = get_rust_root(); + let cargo_toml = get_cargo_toml_path(&rust_root); + let changelog_dir = get_changelog_dir(&rust_root); + let changelog_file = get_changelog_path(&rust_root); + + let version = match get_version_from_cargo(&cargo_toml) { + Ok(v) => v, + Err(e) => { + eprintln!("Error: {}", e); + exit(1); + } + }; + + println!("Collecting changelog fragments for version {}", version); + + let fragments = collect_fragments(&changelog_dir); + + if fragments.is_empty() { + println!("No changelog fragments found"); + exit(0); + } + + update_changelog(&changelog_file, &version, &fragments); + remove_fragments(&changelog_dir); + + println!("Changelog collection complete"); +} diff --git a/scripts/create-changelog-fragment.rs b/scripts/create-changelog-fragment.rs new file mode 100644 index 0000000..08e145b --- /dev/null +++ b/scripts/create-changelog-fragment.rs @@ -0,0 +1,119 @@ +#!/usr/bin/env rust-script +//! Create a changelog fragment for manual release PR +//! +//! This script creates a changelog fragment with the appropriate +//! category based on the bump type. +//! +//! Usage: rust-script scripts/create-changelog-fragment.rs --bump-type [--description ] +//! +//! ```cargo +//! [dependencies] +//! chrono = "0.4" +//! ``` + +use std::env; +use std::fs; +use std::path::Path; +use std::process::exit; +use chrono::Utc; + +fn get_arg(name: &str) -> Option { + let args: Vec = env::args().collect(); + let flag = format!("--{}", name); + + if let Some(idx) = args.iter().position(|a| a == &flag) { + return args.get(idx + 1).cloned(); + } + + let env_name = name.to_uppercase().replace('-', "_"); + env::var(&env_name).ok().filter(|s| !s.is_empty()) +} + +fn get_rust_root() -> String { + if let Some(root) = get_arg("rust-root") { + return root; + } + + if let Ok(root) = env::var("RUST_ROOT") { + if !root.is_empty() { + return root; + } + } + + if Path::new("./Cargo.toml").exists() { + return ".".to_string(); + } + + if Path::new("./rust/Cargo.toml").exists() { + return "rust".to_string(); + } + + ".".to_string() +} + +fn get_changelog_dir(rust_root: &str) -> String { + if rust_root == "." { + "changelog.d".to_string() + } else { + format!("{}/changelog.d", rust_root) + } +} + +fn get_category(bump_type: &str) -> &'static str { + match bump_type { + "major" => "### Breaking Changes", + "minor" => "### Added", + "patch" => "### Fixed", + _ => "### Changed", + } +} + +fn generate_timestamp() -> String { + Utc::now().format("%Y%m%d%H%M%S").to_string() +} + +fn main() { + let bump_type = get_arg("bump-type").unwrap_or_else(|| "patch".to_string()); + let description = get_arg("description"); + + // Validate bump type + if !["major", "minor", "patch"].contains(&bump_type.as_str()) { + eprintln!("Invalid bump type: {}. Must be major, minor, or patch.", bump_type); + exit(1); + } + + let rust_root = get_rust_root(); + let changelog_dir = get_changelog_dir(&rust_root); + let timestamp = generate_timestamp(); + let fragment_file = format!("{}/{}-manual-{}.md", changelog_dir, timestamp, bump_type); + + // Determine changelog category based on bump type + let category = get_category(&bump_type); + + // Create changelog fragment with frontmatter + let description_text = description.unwrap_or_else(|| format!("Manual {} release", bump_type)); + let fragment_content = format!( + "---\nbump: {}\n---\n\n{}\n\n- {}\n", + bump_type, category, description_text + ); + + // Ensure changelog directory exists + let dir_path = Path::new(&changelog_dir); + if !dir_path.exists() { + if let Err(e) = fs::create_dir_all(dir_path) { + eprintln!("Error creating directory {}: {}", changelog_dir, e); + exit(1); + } + } + + // Write the fragment file + if let Err(e) = fs::write(&fragment_file, &fragment_content) { + eprintln!("Error writing fragment file: {}", e); + exit(1); + } + + println!("Created changelog fragment: {}", fragment_file); + println!(); + println!("Content:"); + println!("{}", fragment_content); +} diff --git a/scripts/create-github-release.mjs b/scripts/create-github-release.mjs deleted file mode 100644 index 1d82c96..0000000 --- a/scripts/create-github-release.mjs +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env node - -/** - * Create GitHub Release from CHANGELOG.md - * Usage: node scripts/create-github-release.mjs --release-version --repository - * - * Uses link-foundation libraries: - * - use-m: Dynamic package loading without package.json dependencies - * - command-stream: Modern shell command execution with streaming support - * - lino-arguments: Unified configuration from CLI args, env vars, and .lenv files - */ - -import { readFileSync, existsSync } from 'fs'; - -// Load use-m dynamically -const { use } = eval( - await (await fetch('https://unpkg.com/use-m/use.js')).text() -); - -// Import link-foundation libraries -const { $ } = await use('command-stream'); -const { makeConfig } = await use('lino-arguments'); - -// Parse CLI arguments -// Note: Using --release-version instead of --version to avoid conflict with yargs' built-in --version flag -const config = makeConfig({ - yargs: ({ yargs, getenv }) => - yargs - .option('release-version', { - type: 'string', - default: getenv('VERSION', ''), - describe: 'Version number (e.g., 1.0.0)', - }) - .option('repository', { - type: 'string', - default: getenv('REPOSITORY', ''), - describe: 'GitHub repository (e.g., owner/repo)', - }), -}); - -const { releaseVersion: version, repository } = config; - -if (!version || !repository) { - console.error('Error: Missing required arguments'); - console.error( - 'Usage: node scripts/create-github-release.mjs --release-version --repository ' - ); - process.exit(1); -} - -const tag = `v${version}`; - -console.log(`Creating GitHub release for ${tag}...`); - -/** - * Extract changelog content for a specific version - * @param {string} version - * @returns {string} - */ -function getChangelogForVersion(version) { - const changelogPath = 'CHANGELOG.md'; - - if (!existsSync(changelogPath)) { - return `Release v${version}`; - } - - const content = readFileSync(changelogPath, 'utf-8'); - - // Find the section for this version - const escapedVersion = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const pattern = new RegExp( - `## \\[${escapedVersion}\\].*?\\n([\\s\\S]*?)(?=\\n## \\[|$)` - ); - const match = content.match(pattern); - - if (match) { - return match[1].trim(); - } - - return `Release v${version}`; -} - -try { - const releaseNotes = getChangelogForVersion(version); - - // Create release using GitHub API with JSON input - // This avoids shell escaping issues - const payload = JSON.stringify({ - tag_name: tag, - name: `v${version}`, - body: releaseNotes, - }); - - try { - await $`gh api repos/${repository}/releases -X POST --input -`.run({ - stdin: payload, - }); - console.log(`Created GitHub release: ${tag}`); - } catch (error) { - // Check if release already exists - if (error.message && error.message.includes('already exists')) { - console.log(`Release ${tag} already exists, skipping`); - } else { - throw error; - } - } -} catch (error) { - console.error('Error creating release:', error.message); - process.exit(1); -} diff --git a/scripts/create-github-release.rs b/scripts/create-github-release.rs new file mode 100644 index 0000000..fb45ae8 --- /dev/null +++ b/scripts/create-github-release.rs @@ -0,0 +1,215 @@ +#!/usr/bin/env rust-script +//! Create GitHub Release from CHANGELOG.md +//! +//! Automatically includes crates.io and docs.rs badges in release notes +//! when the crate name can be detected from Cargo.toml. +//! +//! Usage: rust-script scripts/create-github-release.rs --release-version --repository [--tag-prefix ] [--release-label