diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 10a646e..df8905f 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -18,6 +18,14 @@ on: - 'changelog.d/**' 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 @@ -38,31 +46,126 @@ concurrency: env: CARGO_TERM_COLOR: always RUSTFLAGS: -Dwarnings + # Support both CARGO_REGISTRY_TOKEN (cargo's native env var) and CARGO_TOKEN (for backwards compatibility) + CARGO_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }} defaults: run: working-directory: rust 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@v4 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: cargo install rust-script + working-directory: . + + - 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 + working-directory: . + + # === CHANGELOG CHECK - only runs on PRs with code changes === + # Docs-only PRs (./docs folder, markdown files) don't require changelog fragments + 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@v4 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: cargo install rust-script + working-directory: . + + - name: Check for changelog fragments + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: rust-script scripts/check-changelog-fragment.rs + working-directory: . + + # === VERSION CHECK - prevents manual version modification in PRs === + # This ensures versions are only modified by the automated release pipeline + version-check: + name: Version Modification Check + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: cargo install rust-script + working-directory: . - # Linting and formatting + - 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 + working-directory: . + + # === LINT AND FORMAT CHECK === + # Lint runs independently of changelog check - it's a fast check that should always run lint: name: Lint and Format Check runs-on: ubuntu-latest + needs: [detect-changes] + # Note: always() is required because detect-changes is skipped on workflow_dispatch, + # and without always(), this job would also be skipped even though its condition includes workflow_dispatch. + # See: https://github.com/actions/runner/issues/491 + 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 - - name: Install Rust toolchain + - name: Setup Rust uses: dtolnay/rust-toolchain@stable with: - components: rustfmt + 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 + working-directory: . - name: Cache cargo registry uses: actions/cache@v4 @@ -78,14 +181,21 @@ jobs: - name: Check formatting run: cargo fmt --all -- --check + - name: Run Clippy + run: cargo clippy --all-targets --all-features + - name: Check file size limit + run: rust-script scripts/check-file-size.rs working-directory: . - run: node scripts/check-file-size.mjs - # Test on multiple OS + # === TEST === + # Test runs independently of changelog check test: name: Test (${{ matrix.os }}) runs-on: ${{ matrix.os }} + needs: [detect-changes, changelog] + # Run if: push event, OR changelog succeeded, OR changelog was skipped (docs-only PR) + if: always() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changelog.result == 'success' || needs.changelog.result == 'skipped') strategy: fail-fast: false matrix: @@ -93,7 +203,7 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Rust toolchain + - name: Setup Rust uses: dtolnay/rust-toolchain@stable - name: Cache cargo registry @@ -110,14 +220,17 @@ jobs: - name: Run tests run: cargo test --all-features --verbose - # Code coverage + - name: Run doc tests + run: cargo test --doc --verbose + + # === CODE COVERAGE === coverage: name: Code Coverage runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Install Rust toolchain + - name: Setup Rust uses: dtolnay/rust-toolchain@stable with: components: llvm-tools-preview @@ -145,15 +258,17 @@ jobs: files: rust/lcov.info fail_ci_if_error: false + # === BUILD === # Build package - only runs if lint and test pass 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 - - name: Install Rust toolchain + - name: Setup Rust uses: dtolnay/rust-toolchain@stable - name: Cache cargo registry @@ -170,48 +285,21 @@ jobs: - name: Build release run: cargo build --release --verbose - # 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 - working-directory: . - 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 "^(rust/src/|rust/tests/|scripts/)" | 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" + - name: Check package + run: cargo package --list + # === AUTO RELEASE === # Automatic release on push to main using changelog fragments # This job automatically bumps version based on fragments in changelog.d/ auto-release: name: Auto Release - needs: [lint, test, build, coverage] - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: [lint, test, build] + # Note: always() ensures consistent behavior with other jobs that depend on jobs using always(). + if: | + always() && !cancelled() && + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + needs.build.result == 'success' runs-on: ubuntu-latest permissions: contents: write @@ -221,62 +309,42 @@ jobs: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - - name: Install Rust toolchain + - 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 + working-directory: . - 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 working-directory: . - name: Determine bump type from changelog fragments id: bump_type + run: rust-script scripts/get-bump-type.rs working-directory: . - run: node scripts/get-bump-type.mjs - name: Check if version already released or no fragments id: check + env: + HAS_FRAGMENTS: ${{ steps.bump_type.outputs.has_fragments }} + run: rust-script scripts/check-release-needed.rs working-directory: . - 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 = ")[^"]*' rust/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 - name: Collect changelog and bump version id: version if: steps.check.outputs.should_release == 'true' && steps.check.outputs.skip_bump != 'true' - working-directory: . run: | - node scripts/version-and-commit.mjs \ + rust-script scripts/version-and-commit.rs \ --bump-type "${{ steps.bump_type.outputs.bump_type }}" + working-directory: . - name: Get current version id: current_version if: steps.check.outputs.should_release == 'true' + run: rust-script scripts/get-version.rs working-directory: . - run: | - CURRENT_VERSION=$(grep -Po '(?<=^version = ")[^"]*' rust/Cargo.toml) - echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT - name: Build release if: steps.check.outputs.should_release == 'true' @@ -284,33 +352,31 @@ jobs: - 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 }} - run: | - if [ -n "$CARGO_REGISTRY_TOKEN" ]; then - echo "Publishing to crates.io..." - cargo publish --token "$CARGO_REGISTRY_TOKEN" - else - echo "::error::CARGO_REGISTRY_TOKEN or CARGO_TOKEN secret is not configured. Skipping crates.io publish." - echo "Please configure a crates.io API token in repository secrets." - exit 1 - fi + CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} + run: rust-script scripts/publish-crate.rs + working-directory: . - name: Create GitHub Release if: steps.check.outputs.should_release == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: rust-script scripts/create-github-release.rs --release-version "${{ steps.current_version.outputs.version }}" --repository "${{ github.repository }}" working-directory: . - run: | - node scripts/create-github-release.mjs \ - --release-version "${{ steps.current_version.outputs.version }}" \ - --repository "${{ github.repository }}" + # === MANUAL INSTANT RELEASE === # Manual release via workflow_dispatch - only after CI passes manual-release: - name: Manual Release - needs: [lint, test, build, coverage] - if: github.event_name == 'workflow_dispatch' + name: Instant Release + needs: [lint, test, build] + # Note: always() is required to evaluate the condition when dependencies use always(). + # The build job ensures lint and test passed before this job runs. + 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 @@ -320,39 +386,28 @@ jobs: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - - name: Install Rust toolchain + - 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 + working-directory: . - 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 working-directory: . - name: Collect changelog fragments + run: rust-script scripts/collect-changelog.rs working-directory: . - 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 - name: Version and commit id: version + 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 }}" working-directory: . - run: | - node scripts/version-and-commit.mjs \ - --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' @@ -360,24 +415,65 @@ jobs: - 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 }} - run: | - if [ -n "$CARGO_REGISTRY_TOKEN" ]; then - echo "Publishing to crates.io..." - cargo publish --token "$CARGO_REGISTRY_TOKEN" - else - echo "::error::CARGO_REGISTRY_TOKEN or CARGO_TOKEN secret is not configured. Skipping crates.io publish." - echo "Please configure a crates.io API token in repository secrets." - exit 1 - fi + CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} + run: rust-script scripts/publish-crate.rs + working-directory: . - name: Create GitHub Release if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: rust-script scripts/create-github-release.rs --release-version "${{ steps.version.outputs.new_version }}" --repository "${{ github.repository }}" working-directory: . - run: | - node scripts/create-github-release.mjs \ - --release-version "${{ steps.version.outputs.new_version }}" \ - --repository "${{ github.repository }}" + + # === 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@v4 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: cargo install rust-script + working-directory: . + + - 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 }}" + working-directory: . + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7 + 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/changelog.d/20260321_220000_migrate_cicd_to_rust_scripts.md b/changelog.d/20260321_220000_migrate_cicd_to_rust_scripts.md new file mode 100644 index 0000000..61fb430 --- /dev/null +++ b/changelog.d/20260321_220000_migrate_cicd_to_rust_scripts.md @@ -0,0 +1,27 @@ +--- +bump: minor +--- + +### Changed + +- Migrated all CI/CD pipeline scripts from Node.js (.mjs) to Rust (.rs) using rust-script +- Updated workflow to use rust-script for all script execution, eliminating Node.js dependency + +### Added + +- Smart change detection job (`detect-code-changes.rs`) to skip unnecessary CI jobs +- Version modification check to prevent manual version edits in PRs +- Crates.io-based release check instead of git tag check (`check-release-needed.rs`) +- Graceful crates.io publish script with auth error handling (`publish-crate.rs`) +- Clippy lint check in CI pipeline +- Doc tests (`cargo test --doc`) in CI pipeline +- Package verification (`cargo package --list`) in CI build +- PR-diff-aware changelog fragment validation (`check-changelog-fragment.rs`) +- Changelog PR release mode for manual release workflow +- Multi-language repository path detection utility (`rust-paths.rs`) +- Case study documentation for issue #132 + +### Fixed + +- CI/CD "Auto Release" failure caused by hard-failing on missing `CARGO_REGISTRY_TOKEN` +- Job dependency conditions now use `always() && !cancelled()` pattern for correct behavior diff --git a/docs/case-studies/issue-132/README.md b/docs/case-studies/issue-132/README.md new file mode 100644 index 0000000..5cee19d --- /dev/null +++ b/docs/case-studies/issue-132/README.md @@ -0,0 +1,91 @@ +# Case Study: Issue #132 — CI/CD Missing Version Bump from Template + +## Summary + +The CI/CD pipeline in linksplatform/Numbers was failing at the "Auto Release" step +because it had diverged from the best practices established in the +[rust-ai-driven-development-pipeline-template](https://github.com/link-foundation/rust-ai-driven-development-pipeline-template). + +## Timeline + +- **2026-03-21T22:29:19Z**: CI/CD run [23390203130](https://github.com/linksplatform/Numbers/actions/runs/23390203130) triggered on push to main +- **2026-03-21T22:30:51Z**: "Auto Release" job failed at "Publish to Crates.io" step (job [68043615120](https://github.com/linksplatform/Numbers/actions/runs/23390203130/job/68043615120)) +- All other jobs (lint, test, coverage, build) passed successfully + +## Root Cause Analysis + +### Primary Failure: Missing `CARGO_REGISTRY_TOKEN` Secret + +The publish step used inline bash that hard-failed (`exit 1`) when no token was configured: + +```bash +if [ -n "$CARGO_REGISTRY_TOKEN" ]; then + cargo publish --token "$CARGO_REGISTRY_TOKEN" +else + echo "::error::CARGO_REGISTRY_TOKEN or CARGO_TOKEN secret is not configured." + exit 1 # <-- Hard failure, no graceful handling +fi +``` + +The template's `publish-crate.rs` script handles this more gracefully: +- Warns when no token is set but still attempts publish +- Handles "already exists" responses without failing +- Provides clear diagnostic messages for auth failures +- Uses `--allow-dirty` flag for CI contexts + +### Secondary Issues: Divergence from Template + +Comparing the Numbers repo's CI/CD with the template revealed multiple gaps: + +| Feature | Numbers (Before) | Template | Impact | +|---------|-----------------|----------|--------| +| Script language | Node.js (.mjs) | Rust (.rs) | Dependency on Node.js runtime + unpkg.com | +| Change detection | Path filters only | `detect-code-changes.rs` | No smart job skipping | +| Version check | None | `check-version-modification.rs` | Manual version edits could break pipeline | +| Release check | Git tags | Crates.io API | Tags don't mean package published | +| Publish script | Inline bash | `publish-crate.rs` | No graceful error handling | +| Clippy | Not run | `cargo clippy --all-targets` | Missing lint coverage | +| Doc tests | Not run | `cargo test --doc` | Missing doc test coverage | +| Package check | Not run | `cargo package --list` | No package verification | +| Changelog check | Directory-based | PR-diff-based | False positives from leftover fragments | +| Changelog PR mode | Not available | `changelog-pr` | No manual changelog PR workflow | +| Job conditions | Simple `if` | `always() && !cancelled()` | Jobs could be skipped incorrectly | + +## Solution + +### Changes Made + +1. **Migrated all scripts from Node.js (.mjs) to Rust (.rs)** + - Eliminates runtime dependency on Node.js and external package fetching + - All scripts use `rust-script` for execution + - Scripts auto-detect single-language vs multi-language repo structure + +2. **Added missing scripts from template** + - `publish-crate.rs` — Graceful crates.io publishing + - `check-release-needed.rs` — Checks crates.io instead of git tags + - `check-version-modification.rs` — Prevents manual version edits + - `detect-code-changes.rs` — Smart change detection + - `git-config.rs` — Git user configuration + - `get-version.rs` — Version extraction for CI outputs + - `rust-paths.rs` — Multi-language path detection + - `check-changelog-fragment.rs` — PR-diff-aware validation + - `create-changelog-fragment.rs` — Manual changelog fragment creation + +3. **Updated workflow (`rust.yml`)** + - Added `detect-changes` job + - Added `version-check` job + - Added `changelog-pr` release mode + - Added Clippy, doc tests, and package verification + - Proper `always() && !cancelled()` job conditions + - Replaced inline bash with Rust scripts + +## Lessons Learned + +1. **Check crates.io, not git tags**: Git tags can exist without the package being published. The source of truth for Rust packages is crates.io. +2. **Graceful publish handling**: The publish step should handle "already exists" and auth failures gracefully instead of hard-failing. +3. **Smart change detection**: Running all CI jobs for every change is wasteful. Detect what changed and skip unnecessary jobs. +4. **PR-diff changelog checks**: Checking for fragments in the directory can give false positives when leftover fragments from previous releases exist. Check the PR diff instead. + +## Data Files + +- `ci-logs/ci-run-23390203130.log.gz` — Full CI/CD run log from the failing run diff --git a/docs/case-studies/issue-132/ci-logs/ci-run-23390203130.log.gz b/docs/case-studies/issue-132/ci-logs/ci-run-23390203130.log.gz new file mode 100644 index 0000000..d132688 Binary files /dev/null and b/docs/case-studies/issue-132/ci-logs/ci-run-23390203130.log.gz differ diff --git a/scripts/bump-version.rs b/scripts/bump-version.rs new file mode 100644 index 0000000..ae1bc47 --- /dev/null +++ b/scripts/bump-version.rs @@ -0,0 +1,176 @@ +#!/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(); + } + + 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; + } + + 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..12f3cb7 --- /dev/null +++ b/scripts/check-changelog-fragment.rs @@ -0,0 +1,133 @@ +#!/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::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_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) -> bool { + let source_patterns = [ + Regex::new(r"^(rust/)?src/").unwrap(), + Regex::new(r"^(rust/)?tests/").unwrap(), + Regex::new(r"^scripts/").unwrap(), + Regex::new(r"^(rust/)?Cargo\.toml$").unwrap(), + ]; + + source_patterns.iter().any(|pattern| pattern.is_match(file_path)) +} + +fn is_changelog_fragment(file_path: &str) -> bool { + 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 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!(); + + let source_changes: Vec<&String> = changed_files.iter().filter(|f| is_source_file(f)).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!(); + + let fragments_added: Vec<&String> = changed_files + .iter() + .filter(|f| is_changelog_fragment(f)) + .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!(); + + 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.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..66e510b --- /dev/null +++ b/scripts/check-release-needed.rs @@ -0,0 +1,212 @@ +#!/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) +//! +//! ```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, +} + +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 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); + + if !has_fragments { + 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 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..a401aa8 --- /dev/null +++ b/scripts/check-version-modification.rs @@ -0,0 +1,133 @@ +#!/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::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(); + + 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_cargo_toml_diff() -> String { + let base_ref = env::var("GITHUB_BASE_REF").unwrap_or_else(|_| "main".to_string()); + + exec_ignore_error("git", &["fetch", "origin", &base_ref, "--depth=1"]); + + // Check both root and rust/ Cargo.toml + let diff_root = exec( + "git", + &["diff", &format!("origin/{}...HEAD", base_ref), "--", "Cargo.toml"], + ); + + let diff_rust = exec( + "git", + &["diff", &format!("origin/{}...HEAD", base_ref), "--", "rust/Cargo.toml"], + ); + + format!("{}\n{}", diff_root, diff_rust) +} + +fn has_version_change(diff: &str) -> bool { + if diff.is_empty() { + return false; + } + + 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"); + + 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); + } + + if should_skip_version_check() { + exit(0); + } + + let diff = get_cargo_toml_diff(); + + if diff.trim().is_empty() { + println!("No changes to Cargo.toml detected."); + println!("Version check passed."); + exit(0); + } + + 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 -- rust/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.rs b/scripts/collect-changelog.rs new file mode 100644 index 0000000..25a5126 --- /dev/null +++ b/scripts/collect-changelog.rs @@ -0,0 +1,224 @@ +#!/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 { + "./changelog.d".to_string() +} + +fn get_changelog_path(_rust_root: &str) -> String { + "./CHANGELOG.md".to_string() +} + +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 { + 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 { + 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..cc85748 --- /dev/null +++ b/scripts/create-changelog-fragment.rs @@ -0,0 +1,83 @@ +#!/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_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"); + + if !["major", "minor", "patch"].contains(&bump_type.as_str()) { + eprintln!("Invalid bump type: {}. Must be major, minor, or patch.", bump_type); + exit(1); + } + + let changelog_dir = "changelog.d"; + let timestamp = generate_timestamp(); + let fragment_file = format!("{}/{}-manual-{}.md", changelog_dir, timestamp, bump_type); + + let category = get_category(&bump_type); + + 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 + ); + + 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); + } + } + + 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.rs b/scripts/create-github-release.rs new file mode 100644 index 0000000..03c8a25 --- /dev/null +++ b/scripts/create-github-release.rs @@ -0,0 +1,134 @@ +#!/usr/bin/env rust-script +//! Create GitHub Release from CHANGELOG.md +//! +//! Usage: rust-script scripts/create-github-release.rs --release-version --repository +//! +//! ```cargo +//! [dependencies] +//! regex = "1" +//! serde = { version = "1", features = ["derive"] } +//! serde_json = "1" +//! ``` + +use std::env; +use std::fs; +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio, exit}; +use regex::Regex; +use serde::Serialize; + +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_changelog_for_version(version: &str) -> String { + let changelog_path = "CHANGELOG.md"; + + if !Path::new(changelog_path).exists() { + return format!("Release v{}", version); + } + + let content = match fs::read_to_string(changelog_path) { + Ok(c) => c, + Err(_) => return format!("Release v{}", version), + }; + + let escaped_version = regex::escape(version); + let pattern = format!(r"(?s)## \[{}\].*?\n(.*?)(?=\n## \[|$)", escaped_version); + let re = Regex::new(&pattern).unwrap(); + + if let Some(caps) = re.captures(&content) { + caps.get(1).unwrap().as_str().trim().to_string() + } else { + format!("Release v{}", version) + } +} + +#[derive(Serialize)] +struct ReleasePayload { + tag_name: String, + name: String, + body: String, +} + +fn main() { + let version = match get_arg("release-version") { + Some(v) => v, + None => { + eprintln!("Error: Missing required argument --release-version"); + eprintln!("Usage: rust-script scripts/create-github-release.rs --release-version --repository "); + exit(1); + } + }; + + let repository = match get_arg("repository") { + Some(r) => r, + None => { + eprintln!("Error: Missing required argument --repository"); + eprintln!("Usage: rust-script scripts/create-github-release.rs --release-version --repository "); + exit(1); + } + }; + + let tag_prefix = get_arg("tag-prefix").unwrap_or_else(|| "v".to_string()); + let crates_io_url = get_arg("crates-io-url"); + + let tag = format!("{}{}", tag_prefix, version); + println!("Creating GitHub release for {}...", tag); + + let mut release_notes = get_changelog_for_version(&version); + + if let Some(url) = crates_io_url { + release_notes = format!("{}\n\n{}", url, release_notes); + } + + let payload = ReleasePayload { + tag_name: tag.clone(), + name: format!("{}{}", tag_prefix, version), + body: release_notes, + }; + + let payload_json = serde_json::to_string(&payload).expect("Failed to serialize payload"); + + let mut child = Command::new("gh") + .args([ + "api", + &format!("repos/{}/releases", repository), + "-X", + "POST", + "--input", + "-", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("Failed to execute gh command"); + + if let Some(ref mut stdin) = child.stdin { + stdin.write_all(payload_json.as_bytes()).expect("Failed to write to stdin"); + } + + let output = child.wait_with_output().expect("Failed to wait on gh command"); + + if output.status.success() { + println!("Created GitHub release: {}", tag); + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("already exists") { + println!("Release {} already exists, skipping", tag); + } else { + eprintln!("Error creating release: {}", stderr); + exit(1); + } + } +} diff --git a/scripts/detect-code-changes.rs b/scripts/detect-code-changes.rs new file mode 100644 index 0000000..a855171 --- /dev/null +++ b/scripts/detect-code-changes.rs @@ -0,0 +1,177 @@ +#!/usr/bin/env rust-script +//! Detect code changes for CI/CD pipeline +//! +//! This script detects what types of files have changed between two commits +//! and outputs the results for use in GitHub Actions workflow conditions. +//! +//! Key behavior: +//! - For PRs: compares PR head against base branch +//! - For pushes: compares HEAD against HEAD^ +//! - Excludes certain folders and file types from "code changes" detection +//! +//! Excluded from code changes (don't require changelog fragments): +//! - Markdown files (*.md) in any folder +//! - changelog.d/ folder (changelog fragments) +//! - docs/ folder (documentation) +//! - experiments/ folder (experimental scripts) +//! - examples/ folder (example scripts) +//! +//! Usage: rust-script scripts/detect-code-changes.rs +//! +//! Environment variables (set by GitHub Actions): +//! - GITHUB_EVENT_NAME: 'pull_request' or 'push' +//! - GITHUB_BASE_SHA: Base commit SHA for PR +//! - GITHUB_HEAD_SHA: Head commit SHA for PR +//! +//! Outputs (written to GITHUB_OUTPUT): +//! - rs-changed: 'true' if any .rs files changed +//! - toml-changed: 'true' if any .toml files changed +//! - mjs-changed: 'true' if any .mjs files changed +//! - docs-changed: 'true' if any .md files changed +//! - workflow-changed: 'true' if any .github/workflows/ files changed +//! - any-code-changed: 'true' if any code files changed (excludes docs, changelog.d, experiments, examples) +//! +//! ```cargo +//! [dependencies] +//! regex = "1" +//! ``` + +use std::env; +use std::fs; +use std::io::Write; +use std::process::Command; +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 exec_silent(command: &str, args: &[&str]) { + let _ = Command::new(command) + .args(args) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); +} + +fn set_output(name: &str, value: &str) { + if let Ok(output_file) = env::var("GITHUB_OUTPUT") { + if let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(&output_file) { + let _ = writeln!(file, "{}={}", name, value); + } + } + println!("{}={}", name, value); +} + +fn get_changed_files() -> Vec { + let event_name = env::var("GITHUB_EVENT_NAME").unwrap_or_else(|_| "local".to_string()); + + if event_name == "pull_request" { + let base_sha = env::var("GITHUB_BASE_SHA").ok(); + let head_sha = env::var("GITHUB_HEAD_SHA").ok(); + + if let (Some(base), Some(head)) = (base_sha, head_sha) { + println!("Comparing PR: {}...{}", base, head); + + exec_silent("git", &["fetch", "origin", &base]); + + let output = exec("git", &["diff", "--name-only", &base, &head]); + if !output.is_empty() { + return output.lines().filter(|s| !s.is_empty()).map(String::from).collect(); + } + } + } + + println!("Comparing HEAD^ to HEAD"); + let output = exec("git", &["diff", "--name-only", "HEAD^", "HEAD"]); + + if output.is_empty() { + println!("HEAD^ not available, listing all files in HEAD"); + let output = exec("git", &["ls-tree", "--name-only", "-r", "HEAD"]); + return output.lines().filter(|s| !s.is_empty()).map(String::from).collect(); + } + + output.lines().filter(|s| !s.is_empty()).map(String::from).collect() +} + +fn is_excluded_from_code_changes(file_path: &str) -> bool { + if file_path.ends_with(".md") { + return true; + } + + let excluded_folders = ["changelog.d/", "docs/", "experiments/", "examples/"]; + + for folder in &excluded_folders { + if file_path.starts_with(folder) { + return true; + } + } + + false +} + +fn main() { + println!("Detecting file changes for CI/CD...\n"); + + let changed_files = get_changed_files(); + + println!("Changed files:"); + if changed_files.is_empty() { + println!(" (none)"); + } else { + for file in &changed_files { + println!(" {}", file); + } + } + println!(); + + let rs_changed = changed_files.iter().any(|f| f.ends_with(".rs")); + set_output("rs-changed", if rs_changed { "true" } else { "false" }); + + let toml_changed = changed_files.iter().any(|f| f.ends_with(".toml")); + set_output("toml-changed", if toml_changed { "true" } else { "false" }); + + let mjs_changed = changed_files.iter().any(|f| f.ends_with(".mjs")); + set_output("mjs-changed", if mjs_changed { "true" } else { "false" }); + + let docs_changed = changed_files.iter().any(|f| f.ends_with(".md")); + set_output("docs-changed", if docs_changed { "true" } else { "false" }); + + let workflow_changed = changed_files.iter().any(|f| f.starts_with(".github/workflows/")); + set_output("workflow-changed", if workflow_changed { "true" } else { "false" }); + + let code_changed_files: Vec<&String> = changed_files + .iter() + .filter(|f| !is_excluded_from_code_changes(f)) + .collect(); + + println!("\nFiles considered as code changes:"); + if code_changed_files.is_empty() { + println!(" (none)"); + } else { + for file in &code_changed_files { + println!(" {}", file); + } + } + println!(); + + let code_pattern = Regex::new(r"\.(rs|toml|mjs|js|yml|yaml)$|\.github/workflows/").unwrap(); + let code_changed = code_changed_files.iter().any(|f| code_pattern.is_match(f)); + set_output("any-code-changed", if code_changed { "true" } else { "false" }); + + println!("\nChange detection completed."); +} diff --git a/scripts/get-bump-type.rs b/scripts/get-bump-type.rs new file mode 100644 index 0000000..3f3abed --- /dev/null +++ b/scripts/get-bump-type.rs @@ -0,0 +1,149 @@ +#!/usr/bin/env rust-script +//! Parse changelog fragments and determine version bump type +//! +//! This script reads changeset fragments from changelog.d/ and determines +//! the version bump type based on the frontmatter in each fragment. +//! +//! Supports both single-language and multi-language repository structures: +//! - Single-language: changelog.d/ in repository root +//! - Multi-language: changelog.d/ in rust/ subfolder +//! +//! Fragment format: +//! --- +//! bump: patch|minor|major +//! --- +//! +//! ### Added +//! - Your changes here +//! +//! Usage: rust-script scripts/get-bump-type.rs [--default ] [--rust-root ] +//! +//! ```cargo +//! [dependencies] +//! regex = "1" +//! ``` + +use std::env; +use std::fs; +use std::io::Write; +use std::path::Path; +use std::process::exit; +use regex::Regex; + +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_changelog_dir() -> String { + // In multi-language repos, changelog.d is at repo root + "./changelog.d".to_string() +} + +fn set_output(key: &str, value: &str) { + if let Ok(output_file) = env::var("GITHUB_OUTPUT") { + if let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(&output_file) { + let _ = writeln!(file, "{}={}", key, value); + } + } + println!("Output: {}={}", key, value); +} + +fn bump_priority(bump_type: &str) -> u8 { + match bump_type { + "patch" => 1, + "minor" => 2, + "major" => 3, + _ => 0, + } +} + +fn parse_frontmatter(content: &str) -> Option { + let re = Regex::new(r"(?s)^---\s*\n(.*?)\n---").unwrap(); + + if let Some(caps) = re.captures(content) { + let frontmatter = caps.get(1).unwrap().as_str(); + + for line in frontmatter.lines() { + let bump_re = Regex::new(r"^\s*bump\s*:\s*(.+?)\s*$").unwrap(); + if let Some(bump_caps) = bump_re.captures(line) { + return Some(bump_caps.get(1).unwrap().as_str().to_string()); + } + } + } + + None +} + +fn determine_bump_type(changelog_dir: &str, default_bump: &str) -> (String, usize) { + let dir_path = Path::new(changelog_dir); + if !dir_path.exists() { + println!("No {} directory found", changelog_dir); + return (default_bump.to_string(), 0); + } + + 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(_) => { + println!("No changelog fragments found"); + return (default_bump.to_string(), 0); + } + }; + + if files.is_empty() { + println!("No changelog fragments found"); + return (default_bump.to_string(), 0); + } + + files.sort(); + + let mut highest_priority: u8 = 0; + let mut highest_bump_type = default_bump.to_string(); + + for file in &files { + if let Ok(content) = fs::read_to_string(file) { + if let Some(bump) = parse_frontmatter(&content) { + let priority = bump_priority(&bump); + if priority > highest_priority { + highest_priority = priority; + highest_bump_type = bump.clone(); + } + println!("Fragment {}: bump={}", file.file_name().unwrap().to_string_lossy(), bump); + } else { + println!( + "Fragment {}: no bump specified, using default", + file.file_name().unwrap().to_string_lossy() + ); + } + } + } + + (highest_bump_type, files.len()) +} + +fn main() { + let default_bump = get_arg("default").unwrap_or_else(|| "patch".to_string()); + let changelog_dir = get_changelog_dir(); + + let (bump_type, fragment_count) = determine_bump_type(&changelog_dir, &default_bump); + + println!("\nDetermined bump type: {} (from {} fragment(s))", bump_type, fragment_count); + + set_output("bump_type", &bump_type); + set_output("fragment_count", &fragment_count.to_string()); + set_output("has_fragments", if fragment_count > 0 { "true" } else { "false" }); +} diff --git a/scripts/get-version.rs b/scripts/get-version.rs new file mode 100644 index 0000000..3496e10 --- /dev/null +++ b/scripts/get-version.rs @@ -0,0 +1,107 @@ +#!/usr/bin/env rust-script +//! Get the current version from Cargo.toml +//! +//! This script reads the version from Cargo.toml and outputs it +//! for use in GitHub Actions. +//! +//! 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/get-version.rs [--rust-root ] +//! +//! Outputs (written to GITHUB_OUTPUT): +//! - version: The current version from Cargo.toml +//! +//! ```cargo +//! [dependencies] +//! regex = "1" +//! ``` + +use std::env; +use std::fs; +use std::path::Path; +use std::process::exit; +use regex::Regex; + +fn get_rust_root() -> String { + let args: Vec = env::args().collect(); + if let Some(idx) = args.iter().position(|a| a == "--rust-root") { + if let Some(root) = args.get(idx + 1) { + return root.clone(); + } + } + + if let Ok(root) = env::var("RUST_ROOT") { + if !root.is_empty() { + 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 main() { + let rust_root = get_rust_root(); + let cargo_toml = get_cargo_toml_path(&rust_root); + + match get_current_version(&cargo_toml) { + Ok(version) => { + println!("Current version: {}", version); + set_output("version", &version); + } + Err(e) => { + eprintln!("Error: {}", e); + exit(1); + } + } +} diff --git a/scripts/git-config.rs b/scripts/git-config.rs new file mode 100644 index 0000000..850089a --- /dev/null +++ b/scripts/git-config.rs @@ -0,0 +1,61 @@ +#!/usr/bin/env rust-script +//! Configure git user for CI/CD pipeline +//! +//! This script sets up the git user name and email for automated commits. +//! It's used by the CI/CD pipeline before making commits. +//! +//! Usage: rust-script scripts/git-config.rs [--name ] [--email ] +//! +//! Environment variables: +//! - GIT_USER_NAME: Git user name (default: github-actions[bot]) +//! - GIT_USER_EMAIL: Git user email (default: github-actions[bot]@users.noreply.github.com) + +use std::env; +use std::process::{Command, exit}; + +fn get_arg(name: &str, default: &str) -> String { + let args: Vec = env::args().collect(); + let flag = format!("--{}", name); + + if let Some(idx) = args.iter().position(|a| a == &flag) { + if let Some(value) = args.get(idx + 1) { + return value.clone(); + } + } + + let env_name = format!("GIT_USER_{}", name.to_uppercase()); + env::var(&env_name).unwrap_or_else(|_| default.to_string()) +} + +fn run_command(cmd: &str, args: &[&str]) -> Result<(), String> { + let output = Command::new(cmd) + .args(args) + .output() + .map_err(|e| format!("Failed to execute {}: {}", cmd, e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Command failed: {}", stderr)); + } + + Ok(()) +} + +fn main() { + let name = get_arg("name", "github-actions[bot]"); + let email = get_arg("email", "github-actions[bot]@users.noreply.github.com"); + + println!("Configuring git user: {} <{}>", name, email); + + if let Err(e) = run_command("git", &["config", "user.name", &name]) { + eprintln!("Error configuring git name: {}", e); + exit(1); + } + + if let Err(e) = run_command("git", &["config", "user.email", &email]) { + eprintln!("Error configuring git email: {}", e); + exit(1); + } + + println!("Git configuration complete"); +} diff --git a/scripts/publish-crate.rs b/scripts/publish-crate.rs new file mode 100644 index 0000000..451de83 --- /dev/null +++ b/scripts/publish-crate.rs @@ -0,0 +1,203 @@ +#!/usr/bin/env rust-script +//! Publish package to crates.io +//! +//! This script publishes the Rust package to crates.io and handles +//! the case where the version already exists. +//! +//! 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/publish-crate.rs [--token ] [--rust-root ] +//! +//! Environment variables (checked in order of priority): +//! - CARGO_REGISTRY_TOKEN: Cargo's native crates.io token (preferred) +//! - CARGO_TOKEN: Alternative token name for backwards compatibility +//! +//! Outputs (written to GITHUB_OUTPUT): +//! - publish_result: 'success', 'already_exists', or 'failed' +//! +//! ```cargo +//! [dependencies] +//! regex = "1" +//! ``` + +use std::env; +use std::fs; +use std::io::Write; +use std::path::Path; +use std::process::{Command, exit}; +use regex::Regex; + +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(); + } + + None +} + +fn get_rust_root() -> String { + if let Some(root) = get_arg("rust-root") { + eprintln!("Using explicitly configured Rust root: {}", root); + return root; + } + + if let Ok(root) = env::var("RUST_ROOT") { + if !root.is_empty() { + eprintln!("Using environment 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 needs_cd(rust_root: &str) -> bool { + rust_root != "." +} + +fn set_output(key: &str, value: &str) { + if let Ok(output_file) = env::var("GITHUB_OUTPUT") { + if let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(&output_file) { + let _ = writeln!(file, "{}={}", key, value); + } + } + println!("Output: {}={}", key, value); +} + +fn get_package_info(cargo_toml_path: &str) -> Result<(String, String), String> { + let content = fs::read_to_string(cargo_toml_path) + .map_err(|e| format!("Failed to read {}: {}", cargo_toml_path, e))?; + + let name_re = Regex::new(r#"(?m)^name\s*=\s*"([^"]+)""#).unwrap(); + let version_re = Regex::new(r#"(?m)^version\s*=\s*"([^"]+)""#).unwrap(); + + let name = name_re + .captures(&content) + .map(|c| c.get(1).unwrap().as_str().to_string()) + .ok_or_else(|| format!("Could not find name in {}", cargo_toml_path))?; + + let version = version_re + .captures(&content) + .map(|c| c.get(1).unwrap().as_str().to_string()) + .ok_or_else(|| format!("Could not find version in {}", cargo_toml_path))?; + + Ok((name, version)) +} + +fn main() { + let rust_root = get_rust_root(); + let cargo_toml = get_cargo_toml_path(&rust_root); + + // Get token from CLI arg, then env vars + let token = get_arg("token") + .or_else(|| env::var("CARGO_REGISTRY_TOKEN").ok().filter(|s| !s.is_empty())) + .or_else(|| env::var("CARGO_TOKEN").ok().filter(|s| !s.is_empty())); + + let (name, version) = match get_package_info(&cargo_toml) { + Ok(info) => info, + Err(e) => { + eprintln!("Error: {}", e); + exit(1); + } + }; + + println!("Package: {}@{}", name, version); + println!(); + println!("=== Attempting to publish to crates.io ==="); + + if token.is_none() { + println!("::warning::Neither CARGO_REGISTRY_TOKEN nor CARGO_TOKEN is set, attempting publish without explicit token"); + println!(); + println!("To fix this, ensure one of the following secrets is configured:"); + println!(" - CARGO_REGISTRY_TOKEN (Cargo's native env var, preferred)"); + println!(" - CARGO_TOKEN (alternative for backwards compatibility)"); + println!(); + println!("For organization secrets, you may need to map the secret name in your workflow:"); + println!(" env:"); + println!(" CARGO_REGISTRY_TOKEN: ${{{{ secrets.CARGO_TOKEN }}}}"); + println!(); + } else { + println!("Using provided authentication token"); + } + + // Build the cargo publish command + let mut cmd = Command::new("cargo"); + cmd.arg("publish").arg("--allow-dirty"); + + if let Some(t) = &token { + cmd.arg("--token").arg(t); + } + + // For multi-language repos, change to the rust directory + if needs_cd(&rust_root) { + cmd.current_dir(&rust_root); + } + + let output = cmd.output().expect("Failed to execute cargo publish"); + + if output.status.success() { + println!("Successfully published {}@{} to crates.io", name, version); + set_output("publish_result", "success"); + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let combined = format!("{}\n{}", stdout, stderr); + + if combined.contains("already uploaded") || combined.contains("already exists") { + println!("Version {} already exists on crates.io - this is OK", version); + set_output("publish_result", "already_exists"); + } else if combined.contains("non-empty token") + || combined.contains("please provide a") + || combined.contains("unauthorized") + || combined.contains("authentication") + { + eprintln!(); + eprintln!("=== AUTHENTICATION FAILURE ==="); + eprintln!(); + eprintln!("Failed to publish due to missing or invalid authentication token."); + eprintln!(); + eprintln!("SOLUTION: Configure one of these secrets in your repository or organization:"); + eprintln!(" 1. CARGO_REGISTRY_TOKEN - Cargo's native environment variable (preferred)"); + eprintln!(" 2. CARGO_TOKEN - Alternative name for backwards compatibility"); + eprintln!(); + eprintln!("If using organization secrets with a different name, map it in your workflow:"); + eprintln!(" - name: Publish to Crates.io"); + eprintln!(" env:"); + eprintln!(" CARGO_REGISTRY_TOKEN: ${{{{ secrets.YOUR_SECRET_NAME }}}}"); + eprintln!(); + eprintln!("See: https://doc.rust-lang.org/cargo/reference/publishing.html"); + eprintln!(); + set_output("publish_result", "auth_failed"); + exit(1); + } else { + eprintln!("Failed to publish for unknown reason"); + eprintln!("{}", combined); + set_output("publish_result", "failed"); + exit(1); + } + } +} diff --git a/scripts/rust-paths.rs b/scripts/rust-paths.rs new file mode 100644 index 0000000..533d767 --- /dev/null +++ b/scripts/rust-paths.rs @@ -0,0 +1,140 @@ +#!/usr/bin/env rust-script +//! Rust package path detection utility +//! +//! Automatically detects the Rust package root for both: +//! - Single-language repositories (Cargo.toml in root) +//! - Multi-language repositories (Cargo.toml in rust/ subfolder) +//! +//! This utility follows best practices for multi-language monorepo support, +//! allowing scripts to work seamlessly in both repository structures. +//! +//! Usage (as library - import functions from this module): +//! The functions are used by other scripts in this directory. +//! +//! Configuration options (in order of priority): +//! 1. Explicit parameter passed to functions +//! 2. CLI argument: --rust-root +//! 3. Environment variable: RUST_ROOT +//! 4. Auto-detection: Check ./Cargo.toml first, then ./rust/Cargo.toml + +use std::env; +use std::path::{Path, PathBuf}; + +/// Detect Rust package root directory +pub fn get_rust_root(explicit_root: Option<&str>, verbose: bool) -> Result { + if let Some(root) = explicit_root { + if verbose { + eprintln!("Using explicitly configured Rust root: {}", root); + } + return Ok(root.to_string()); + } + + let args: Vec = env::args().collect(); + if let Some(idx) = args.iter().position(|a| a == "--rust-root") { + if let Some(root) = args.get(idx + 1) { + if verbose { + eprintln!("Using CLI configured Rust root: {}", root); + } + return Ok(root.clone()); + } + } + + if let Ok(root) = env::var("RUST_ROOT") { + if !root.is_empty() { + if verbose { + eprintln!("Using environment configured Rust root: {}", root); + } + return Ok(root); + } + } + + if Path::new("./Cargo.toml").exists() { + if verbose { + eprintln!("Detected single-language repository (Cargo.toml in root)"); + } + return Ok(".".to_string()); + } + + if Path::new("./rust/Cargo.toml").exists() { + if verbose { + eprintln!("Detected multi-language repository (Cargo.toml in rust/)"); + } + return Ok("rust".to_string()); + } + + Err( + "Could not find Cargo.toml in expected locations.\n\ + Searched in:\n \ + - ./Cargo.toml (single-language repository)\n \ + - ./rust/Cargo.toml (multi-language repository)\n\n\ + To fix this, either:\n \ + 1. Run the script from the repository root\n \ + 2. Explicitly configure the Rust root using --rust-root option\n \ + 3. Set the RUST_ROOT environment variable" + .to_string(), + ) +} + +/// Get the path to Cargo.toml +pub fn get_cargo_toml_path(rust_root: &str) -> PathBuf { + if rust_root == "." { + PathBuf::from("./Cargo.toml") + } else { + PathBuf::from(rust_root).join("Cargo.toml") + } +} + +/// Get the path to Cargo.lock +pub fn get_cargo_lock_path(rust_root: &str) -> PathBuf { + if rust_root == "." { + PathBuf::from("./Cargo.lock") + } else { + PathBuf::from(rust_root).join("Cargo.lock") + } +} + +/// Get the path to changelog.d directory +pub fn get_changelog_dir(rust_root: &str) -> PathBuf { + if rust_root == "." { + PathBuf::from("./changelog.d") + } else { + PathBuf::from(rust_root).join("changelog.d") + } +} + +/// Get the path to CHANGELOG.md +pub fn get_changelog_path(rust_root: &str) -> PathBuf { + if rust_root == "." { + PathBuf::from("./CHANGELOG.md") + } else { + PathBuf::from(rust_root).join("CHANGELOG.md") + } +} + +/// Check if we need to change directory before running cargo commands +pub fn needs_cd(rust_root: &str) -> bool { + rust_root != "." +} + +/// Parse Rust root from CLI arguments +pub fn parse_rust_root_from_args() -> Option { + let args: Vec = env::args().collect(); + if let Some(idx) = args.iter().position(|a| a == "--rust-root") { + return args.get(idx + 1).cloned(); + } + env::var("RUST_ROOT").ok().filter(|s| !s.is_empty()) +} + +fn main() { + match get_rust_root(None, true) { + Ok(root) => { + println!("Rust root: {}", root); + println!("Cargo.toml: {}", get_cargo_toml_path(&root).display()); + println!("Changelog dir: {}", get_changelog_dir(&root).display()); + } + Err(e) => { + eprintln!("Error: {}", e); + std::process::exit(1); + } + } +} diff --git a/scripts/version-and-commit.rs b/scripts/version-and-commit.rs new file mode 100644 index 0000000..56b9b79 --- /dev/null +++ b/scripts/version-and-commit.rs @@ -0,0 +1,333 @@ +#!/usr/bin/env rust-script +//! Bump version in Cargo.toml and commit changes +//! Used by the CI/CD pipeline for releases +//! +//! 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/version-and-commit.rs --bump-type [--description ] [--rust-root ] +//! +//! ```cargo +//! [dependencies] +//! regex = "1" +//! chrono = "0.4" +//! ``` + +use std::env; +use std::fs; +use std::io::Write; +use std::path::Path; +use std::process::{Command, exit}; +use regex::Regex; +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") { + 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 { + // In multi-language repos like linksplatform/Numbers, changelog.d is at repo root + "./changelog.d".to_string() +} + +fn get_changelog_path(_rust_root: &str) -> String { + // In multi-language repos like linksplatform/Numbers, CHANGELOG.md is at repo root + "./CHANGELOG.md".to_string() +} + +fn set_output(key: &str, value: &str) { + if let Ok(output_file) = env::var("GITHUB_OUTPUT") { + if let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(&output_file) { + let _ = writeln!(file, "{}={}", key, value); + } + } + println!("Output: {}={}", key, value); +} + +fn exec(command: &str, args: &[&str]) -> Result { + match Command::new(command).args(args).output() { + Ok(output) => { + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("Command failed: {}", stderr)) + } + } + Err(e) => Err(format!("Failed to execute: {}", e)), + } +} + +fn exec_check(command: &str, args: &[&str]) -> bool { + Command::new(command) + .args(args) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +struct Version { + major: u32, + minor: u32, + patch: u32, +} + +impl Version { + fn parse(content: &str) -> Option { + let re = Regex::new(r#"(?m)^version\s*=\s*"(\d+)\.(\d+)\.(\d+)""#).ok()?; + let caps = re.captures(content)?; + Some(Version { + major: caps.get(1)?.as_str().parse().ok()?, + minor: caps.get(2)?.as_str().parse().ok()?, + patch: caps.get(3)?.as_str().parse().ok()?, + }) + } + + fn bump(&self, bump_type: &str) -> String { + match bump_type { + "major" => format!("{}.0.0", self.major + 1), + "minor" => format!("{}.{}.0", self.major, self.minor + 1), + _ => format!("{}.{}.{}", self.major, self.minor, self.patch + 1), + } + } +} + +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))?; + + println!("Updated {} to version {}", cargo_toml_path, new_version); + Ok(()) +} + +fn check_tag_exists(version: &str) -> bool { + exec_check("git", &["rev-parse", &format!("v{}", version)]) +} + +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_changelog(changelog_dir: &str, changelog_file: &str, version: &str) { + let dir_path = Path::new(changelog_dir); + if !dir_path.exists() { + return; + } + + 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, + }; + + if files.is_empty() { + return; + } + + files.sort(); + + let fragments: Vec = files + .iter() + .filter_map(|f| fs::read_to_string(f).ok()) + .map(|c| strip_frontmatter(&c)) + .filter(|c| !c.is_empty()) + .collect(); + + if fragments.is_empty() { + return; + } + + let date_str = Utc::now().format("%Y-%m-%d").to_string(); + let new_entry = format!("\n## [{}] - {}\n\n{}\n", version, date_str, fragments.join("\n\n")); + + if Path::new(changelog_file).exists() { + let mut content = fs::read_to_string(changelog_file).unwrap_or_default(); + 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 { + content.push_str(&new_entry); + } + + fs::write(changelog_file, content).expect("Failed to write changelog"); + } + + println!("Collected {} changelog fragment(s)", files.len()); +} + +fn main() { + let bump_type = match get_arg("bump-type") { + Some(bt) => bt, + None => { + eprintln!("Usage: rust-script scripts/version-and-commit.rs --bump-type [--description ] [--rust-root ]"); + exit(1); + } + }; + + if !["major", "minor", "patch"].contains(&bump_type.as_str()) { + eprintln!("Invalid bump type: {}. Must be major, minor, or patch.", bump_type); + exit(1); + } + + let description = get_arg("description"); + 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); + + // Configure git + let _ = exec("git", &["config", "user.name", "github-actions[bot]"]); + let _ = exec("git", &["config", "user.email", "github-actions[bot]@users.noreply.github.com"]); + + // Get current version + let content = match fs::read_to_string(&cargo_toml) { + Ok(c) => c, + Err(e) => { + eprintln!("Error reading {}: {}", cargo_toml, e); + exit(1); + } + }; + + let current = match Version::parse(&content) { + Some(v) => v, + None => { + eprintln!("Error: Could not parse version from {}", cargo_toml); + exit(1); + } + }; + + let new_version = current.bump(&bump_type); + + // Check if this version was already released + if check_tag_exists(&new_version) { + println!("Tag v{} already exists", new_version); + set_output("already_released", "true"); + set_output("new_version", &new_version); + return; + } + + // Update version in Cargo.toml + if let Err(e) = update_cargo_toml(&cargo_toml, &new_version) { + eprintln!("Error: {}", e); + exit(1); + } + + // Collect changelog fragments + collect_changelog(&changelog_dir, &changelog_file, &new_version); + + // Stage Cargo.toml and CHANGELOG.md + let _ = exec("git", &["add", &cargo_toml, &changelog_file]); + + // Check if there are changes to commit + if exec_check("git", &["diff", "--cached", "--quiet"]) { + println!("No changes to commit"); + set_output("version_committed", "false"); + set_output("new_version", &new_version); + return; + } + + // Commit changes + let commit_msg = match &description { + Some(desc) => format!("chore: release v{}\n\n{}", new_version, desc), + None => format!("chore: release v{}", new_version), + }; + + if let Err(e) = exec("git", &["commit", "-m", &commit_msg]) { + eprintln!("Error committing: {}", e); + exit(1); + } + println!("Committed version {}", new_version); + + // Create tag + let tag_msg = match &description { + Some(desc) => format!("Release v{}\n\n{}", new_version, desc), + None => format!("Release v{}", new_version), + }; + + if let Err(e) = exec("git", &["tag", "-a", &format!("v{}", new_version), "-m", &tag_msg]) { + eprintln!("Error creating tag: {}", e); + exit(1); + } + println!("Created tag v{}", new_version); + + // Push changes and tag + if let Err(e) = exec("git", &["push"]) { + eprintln!("Error pushing: {}", e); + exit(1); + } + + if let Err(e) = exec("git", &["push", "--tags"]) { + eprintln!("Error pushing tags: {}", e); + exit(1); + } + println!("Pushed changes and tags"); + + set_output("version_committed", "true"); + set_output("new_version", &new_version); +}