diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index bc9c82e..492c10b 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -35,14 +35,62 @@ on: required: false type: string -concurrency: ${{ github.workflow }}-${{ github.ref }} +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - # Changeset check - only runs on PRs + # === DETECT CHANGES - determines which jobs should run === + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + if: github.event_name != 'workflow_dispatch' + outputs: + mjs-changed: ${{ steps.changes.outputs.mjs-changed }} + js-changed: ${{ steps.changes.outputs.js-changed }} + package-changed: ${{ steps.changes.outputs.package-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: Detect changes + id: changes + working-directory: ./js + 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: node scripts/detect-code-changes.mjs + + # === VERSION CHANGE CHECK === + # Prohibit manual version changes in package.json - versions should only be changed by CI/CD + version-check: + name: Check for Manual Version Changes + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check for version changes in package.json + working-directory: ./js + env: + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_BASE_REF: ${{ github.base_ref }} + run: node scripts/check-version.mjs + + # === CHANGESET CHECK - only runs on PRs with code changes === + # Docs-only PRs (./docs folder, markdown files) don't require changesets changeset-check: name: Check for Changesets runs-on: ubuntu-latest - if: github.event_name == 'pull_request' + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' steps: - uses: actions/checkout@v4 with: @@ -59,6 +107,10 @@ jobs: - name: Check for changesets working-directory: ./js + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | # Skip changeset check for automated version PRs if [[ "${{ github.head_ref }}" == "changeset-release/"* ]]; then @@ -69,12 +121,22 @@ jobs: # Run changeset validation script node scripts/validate-changeset.mjs - # Linting and formatting - runs after changeset check on PRs, immediately on main + # === LINT AND FORMAT CHECK === + # Lint runs independently of changeset-check - it's a fast check that should always run lint: name: Lint and Format Check runs-on: ubuntu-latest - needs: [changeset-check] - if: always() && (github.event_name == 'push' || needs.changeset-check.result == 'success') + needs: [detect-changes] + if: | + always() && !cancelled() && ( + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.mjs-changed == 'true' || + needs.detect-changes.outputs.js-changed == 'true' || + needs.detect-changes.outputs.docs-changed == 'true' || + needs.detect-changes.outputs.package-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' + ) steps: - uses: actions/checkout@v4 @@ -103,8 +165,9 @@ jobs: test: name: Test (Node.js on ${{ matrix.os }}) runs-on: ${{ matrix.os }} - needs: [changeset-check] - if: always() && (github.event_name == 'push' || needs.changeset-check.result == 'success') + needs: [detect-changes, changeset-check] + # Run if: push event, OR changeset-check succeeded, OR changeset-check was skipped (docs-only PR) + if: always() && !cancelled() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changeset-check.result == 'success' || needs.changeset-check.result == 'skipped') strategy: fail-fast: false matrix: @@ -135,7 +198,7 @@ jobs: needs: [lint, test] # Use always() to ensure this job runs even if changeset-check was skipped # This is needed because lint/test jobs have a transitive dependency on changeset-check - if: always() && github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.lint.result == 'success' && needs.test.result == 'success' + if: always() && !cancelled() && github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.lint.result == 'success' && needs.test.result == 'success' runs-on: ubuntu-latest # Permissions required for npm OIDC trusted publishing permissions: @@ -164,11 +227,14 @@ jobs: - name: Check for changesets id: check_changesets working-directory: ./js + run: node scripts/check-changesets.mjs + + - name: Merge multiple changesets + if: steps.check_changesets.outputs.has_changesets == 'true' && steps.check_changesets.outputs.changeset_count > 1 + working-directory: ./js run: | - # Count changeset files (excluding README.md and config.json) - CHANGESET_COUNT=$(find .changeset -name "*.md" ! -name "README.md" | wc -l) - echo "Found $CHANGESET_COUNT changeset file(s)" - echo "has_changesets=$([[ $CHANGESET_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT + echo "Multiple changesets detected, merging..." + node scripts/merge-changesets.mjs - name: Version packages and commit to main if: steps.check_changesets.outputs.has_changesets == 'true' @@ -200,7 +266,14 @@ jobs: # Manual Instant Release - triggered via workflow_dispatch with instant mode instant-release: name: Instant Release - if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant' + needs: [lint, test] + # Note: always() is required to evaluate the condition when dependencies use always() + if: | + always() && !cancelled() && + github.event_name == 'workflow_dispatch' && + github.event.inputs.release_mode == 'instant' && + needs.lint.result == 'success' && + needs.test.result == 'success' runs-on: ubuntu-latest # Permissions required for npm OIDC trusted publishing permissions: diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 71e0f2e..a207713 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -14,6 +14,14 @@ on: - '.github/workflows/rust.yml' 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 @@ -31,11 +39,106 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + jobs: - # Linting and formatting + # === 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 }} + mjs-changed: ${{ steps.changes.outputs.mjs-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 Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Detect changes + id: changes + working-directory: ./rust + 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: node scripts/detect-code-changes.mjs + + # === 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 Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Check for changelog fragments + working-directory: ./rust + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: node scripts/check-changelog-fragment.mjs + + # === 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 Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Check for manual version changes + working-directory: ./rust + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_BASE_REF: ${{ github.base_ref }} + run: node scripts/check-version-modification.mjs + + # === 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 + 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.mjs-changed == 'true' || + needs.detect-changes.outputs.docs-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' + ) steps: - uses: actions/checkout@v4 @@ -44,6 +147,11 @@ jobs: with: components: rustfmt, clippy + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Cache cargo dependencies uses: actions/cache@v4 with: @@ -61,12 +169,20 @@ jobs: - name: Run clippy working-directory: ./rust - run: cargo clippy --all-targets --all-features -- -D warnings + run: cargo clippy --all-targets --all-features - # Test matrix: Rust on multiple OS + - name: Check file size limit + working-directory: ./rust + run: node scripts/check-file-size.mjs + + # === TEST === + # Test runs independently of changelog check test: name: Test (Rust on ${{ 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() && !cancelled() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changelog.result == 'success' || needs.changelog.result == 'skipped') strategy: fail-fast: false matrix: @@ -92,15 +208,21 @@ jobs: working-directory: ./rust run: cargo test --verbose + - name: Run doc tests + working-directory: ./rust + run: cargo test --doc --verbose + - name: Run example working-directory: ./rust run: cargo run --example basic_usage + # === 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 @@ -116,7 +238,7 @@ jobs: ~/.cargo/registry/cache/ ~/.cargo/git/db/ rust/target/ - key: ${{ runner.os }}-cargo-${{ hashFiles('rust/Cargo.lock') }} + key: ${{ runner.os }}-cargo-build-${{ hashFiles('rust/Cargo.lock') }} - name: Build release working-directory: ./rust @@ -124,49 +246,18 @@ jobs: - name: Package crate working-directory: ./rust - run: cargo package --allow-dirty - - # Check for changelog fragments in PRs - changelog: - name: Changelog Fragment Check - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Check for changelog fragments - working-directory: ./rust - run: | - # Get list of fragment files (excluding README and template) - FRAGMENTS=$(find changelog.d -name "*.md" ! -name "README.md" ! -name "*.j2" 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/|tests/|examples/)" | wc -l) - - if [ "$SOURCE_CHANGED" -gt 0 ] && [ "$FRAGMENTS" -eq 0 ]; then - echo "::warning::No changelog fragment found. Please add a changelog fragment in rust/changelog.d/" - echo "" - echo "To create a changelog fragment:" - echo " cd rust/changelog.d" - echo " Create a file: YYYYMMDD_HHMMSS_description.md" - echo "" - echo "See rust/changelog.d/README.md for more information." - # Note: This is a warning, not a failure, to allow flexibility - exit 0 - fi + run: cargo package --list - echo "✓ Changelog check passed" - - # Automatic release on push to main (if version changed) + # === AUTO RELEASE === + # Automatic release on push to main using changelog fragments auto-release: name: Auto Release needs: [lint, test, build] - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: | + always() && !cancelled() && + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + needs.build.result == 'success' runs-on: ubuntu-latest permissions: contents: write @@ -174,6 +265,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Rust uses: dtolnay/rust-toolchain@stable @@ -183,7 +275,16 @@ jobs: with: node-version: '22' - - name: Check if version changed + - name: Configure git + working-directory: ./rust + run: node scripts/git-config.mjs + + - name: Determine bump type from changelog fragments + id: bump_type + working-directory: ./rust + run: node scripts/get-bump-type.mjs + + - name: Check if version already released id: version_check working-directory: ./rust run: | @@ -193,13 +294,34 @@ jobs: # Check if tag exists if git rev-parse "rust-v$CURRENT_VERSION" >/dev/null 2>&1; then - echo "Tag rust-v$CURRENT_VERSION already exists, skipping release" + echo "Tag rust-v$CURRENT_VERSION already exists" echo "should_release=false" >> $GITHUB_OUTPUT else echo "New version detected: $CURRENT_VERSION" echo "should_release=true" >> $GITHUB_OUTPUT fi + - name: Collect changelog and bump version + id: version + if: steps.version_check.outputs.should_release == 'true' && steps.bump_type.outputs.has_fragments == 'true' + working-directory: ./rust + run: | + node scripts/version-and-commit.mjs \ + --bump-type "${{ steps.bump_type.outputs.bump_type }}" + + - name: Get current version + id: current_version + if: steps.version_check.outputs.should_release == 'true' + working-directory: ./rust + run: | + VERSION=$(grep -Po '(?<=^version = ")[^"]*' Cargo.toml) + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Build release + if: steps.version_check.outputs.should_release == 'true' + working-directory: ./rust + run: cargo build --release + - name: Publish to crates.io if: steps.version_check.outputs.should_release == 'true' working-directory: ./rust @@ -219,15 +341,20 @@ jobs: working-directory: ./rust run: | node scripts/create-github-release.mjs \ - --version "${{ steps.version_check.outputs.current_version }}" \ + --version "${{ steps.current_version.outputs.version }}" \ --repository "${{ github.repository }}" \ --tag-prefix "rust-v" - # Manual release via workflow_dispatch + # === MANUAL INSTANT RELEASE === + # Manual release via workflow_dispatch - only after CI passes manual-release: - name: Manual Release + name: Instant Release needs: [lint, test, build] - if: github.event_name == 'workflow_dispatch' + if: | + always() && !cancelled() && + github.event_name == 'workflow_dispatch' && + github.event.inputs.release_mode == 'instant' && + needs.build.result == 'success' runs-on: ubuntu-latest permissions: contents: write @@ -246,17 +373,20 @@ jobs: node-version: '22' - name: Configure git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + working-directory: ./rust + run: node scripts/git-config.mjs + + - name: Collect changelog fragments + working-directory: ./rust + run: node scripts/collect-changelog.mjs - name: Version and commit id: version working-directory: ./rust - run: | - node scripts/version-and-commit.mjs \ - --bump-type "${{ github.event.inputs.bump_type }}" \ - --description "${{ github.event.inputs.description }}" + env: + BUMP_TYPE: ${{ github.event.inputs.bump_type }} + DESCRIPTION: ${{ github.event.inputs.description }} + run: 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' @@ -285,3 +415,62 @@ jobs: --version "${{ steps.version.outputs.new_version }}" \ --repository "${{ github.repository }}" \ --tag-prefix "rust-v" + + # === 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 Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Create changelog fragment + working-directory: ./rust + run: | + # Create timestamp for unique filename + TIMESTAMP=$(date +"%Y%m%d_%H%M%S") + FILENAME="changelog.d/${TIMESTAMP}_manual_release.md" + + # Create the changelog fragment + cat > "$FILENAME" << EOF + --- + bump: ${{ github.event.inputs.bump_type }} + --- + + ${{ github.event.inputs.description || 'Manual release' }} + EOF + + echo "Created changelog fragment: $FILENAME" + + - 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 (Rust)' + body: | + ## Manual Release Request (Rust) + + This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release for the Rust package. + + ### 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/js/.changeset/ci-cd-improvements.md b/js/.changeset/ci-cd-improvements.md new file mode 100644 index 0000000..2a6704c --- /dev/null +++ b/js/.changeset/ci-cd-improvements.md @@ -0,0 +1,12 @@ +--- +'lino-objects-codec': patch +--- + +Add CI/CD improvements based on template best practices: + +- Add detect-code-changes.mjs for smart change detection +- Add check-version.mjs to prevent manual package.json version changes +- Add check-changesets.mjs to check for pending changesets +- Add merge-changesets.mjs to merge multiple changesets on release +- Update workflow with detect-changes job, conditional changeset checks, + and improved concurrency configuration diff --git a/js/scripts/check-changesets.mjs b/js/scripts/check-changesets.mjs new file mode 100644 index 0000000..3f7b7ac --- /dev/null +++ b/js/scripts/check-changesets.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node + +/** + * Check for pending changeset files + * + * This script checks for pending changeset files in the .changeset directory + * and outputs the count and status for use in GitHub Actions workflow conditions. + * + * Usage: + * node scripts/check-changesets.mjs + * + * Outputs (written to GITHUB_OUTPUT): + * - has_changesets: 'true' if there are pending changesets + * - changeset_count: number of changeset files found + */ + +import { readdirSync, existsSync, appendFileSync } from 'fs'; + +const CHANGESET_DIR = '.changeset'; + +/** + * Write output to GitHub Actions output file + * @param {string} name - Output name + * @param {string} value - Output value + */ +function setOutput(name, value) { + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + appendFileSync(outputFile, `${name}=${value}\n`); + } + console.log(`${name}=${value}`); +} + +/** + * Count changeset files in the .changeset directory + * @returns {number} Number of changeset files found + */ +function countChangesetFiles() { + if (!existsSync(CHANGESET_DIR)) { + return 0; + } + + const files = readdirSync(CHANGESET_DIR); + // Filter to only count .md files, excluding README.md + const changesetFiles = files.filter( + (file) => file.endsWith('.md') && file !== 'README.md' + ); + + return changesetFiles.length; +} + +/** + * Main function to check for changesets + */ +function checkChangesets() { + console.log('Checking for pending changeset files...\n'); + + const changesetCount = countChangesetFiles(); + + console.log(`Found ${changesetCount} changeset file(s)`); + + setOutput('has_changesets', changesetCount > 0 ? 'true' : 'false'); + setOutput('changeset_count', String(changesetCount)); +} + +// Run the check +checkChangesets(); diff --git a/js/scripts/check-version.mjs b/js/scripts/check-version.mjs new file mode 100644 index 0000000..84d100b --- /dev/null +++ b/js/scripts/check-version.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node + +/** + * Check for manual version modifications in package.json + * + * This script prevents manual version changes in pull requests. + * Versions should only be changed by the CI/CD pipeline using changesets. + * + * Key behavior: + * - For PRs: compares PR head against base branch to detect version changes + * - Skips check for automated release PRs (changeset-release/* branches) + * - Fails the build if manual version changes are detected + * + * Usage: + * node scripts/check-version.mjs + * + * Environment variables (set by GitHub Actions): + * - GITHUB_HEAD_REF: Branch name of the PR head + * - GITHUB_BASE_REF: Branch name of the PR base + * + * Exit codes: + * - 0: No manual version changes detected (or skipped for release PRs) + * - 1: Manual version changes detected + */ + +import { execSync } from 'child_process'; + +/** + * Execute a shell command and return trimmed output + * @param {string} command - The command to execute + * @returns {string} - The trimmed command output + */ +function exec(command) { + try { + return execSync(command, { encoding: 'utf-8' }).trim(); + } catch (error) { + console.error(`Error executing command: ${command}`); + console.error(error.message); + return ''; + } +} + +/** + * Check if this is an automated release PR that should skip version check + * @returns {boolean} True if version check should be skipped + */ +function shouldSkipVersionCheck() { + const headRef = process.env.GITHUB_HEAD_REF || ''; + + // Skip check for automated release PRs created by changeset + const skipPatterns = ['changeset-release/', 'changeset-manual-release-']; + + for (const pattern of skipPatterns) { + if (headRef.startsWith(pattern)) { + return true; + } + } + + return false; +} + +/** + * Get the version diff from package.json + * @returns {string} The version diff line if found, empty string otherwise + */ +function getVersionDiff() { + const baseRef = process.env.GITHUB_BASE_REF || 'main'; + + // Get the diff for package.json, looking for added lines with "version" + const diffCommand = `git diff origin/${baseRef}...HEAD -- package.json`; + const diff = exec(diffCommand); + + if (!diff) { + return ''; + } + + // Look for added lines (starting with +) containing "version" + // Match pattern: +"version": "x.y.z" + const versionChangePattern = /^\+\s*"version"\s*:\s*"[^"]+"/m; + const match = diff.match(versionChangePattern); + + return match ? match[0] : ''; +} + +/** + * Main function to check for version changes + */ +function checkVersion() { + console.log('Checking for manual version changes in package.json...\n'); + + // Check if we should skip the version check + if (shouldSkipVersionCheck()) { + const headRef = process.env.GITHUB_HEAD_REF || ''; + console.log(`Skipping version check for automated release PR: ${headRef}`); + process.exit(0); + } + + // Get the version diff + const versionDiff = getVersionDiff(); + + if (versionDiff) { + console.error('::error::Manual version change detected in package.json'); + console.error(''); + console.error( + 'Version changes in package.json are prohibited in pull requests.' + ); + console.error( + 'Versions are managed automatically by the CI/CD pipeline using changesets.' + ); + console.error(''); + console.error('To request a release:'); + console.error( + ' 1. Add a changeset file describing your changes (npx changeset)' + ); + console.error( + ' 2. The release workflow will automatically bump the version when merged' + ); + console.error(''); + console.error('Detected change:'); + console.error(versionDiff); + process.exit(1); + } + + console.log('No manual version changes detected - check passed'); + process.exit(0); +} + +// Run the check +checkVersion(); diff --git a/js/scripts/detect-code-changes.mjs b/js/scripts/detect-code-changes.mjs new file mode 100644 index 0000000..a6cd67b --- /dev/null +++ b/js/scripts/detect-code-changes.mjs @@ -0,0 +1,194 @@ +#!/usr/bin/env node + +/** + * 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 changesets): + * - Markdown files (*.md) in any folder + * - .changeset/ folder (changeset metadata) + * - docs/ folder (documentation) + * - experiments/ folder (experimental scripts) + * - examples/ folder (example scripts) + * + * Usage: + * node scripts/detect-code-changes.mjs + * + * 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): + * - mjs-changed: 'true' if any .mjs files changed + * - js-changed: 'true' if any .js files changed + * - package-changed: 'true' if package.json 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, changesets, experiments, examples) + */ + +import { execSync } from 'child_process'; +import { appendFileSync } from 'fs'; + +/** + * Execute a shell command and return trimmed output + * @param {string} command - The command to execute + * @returns {string} - The trimmed command output + */ +function exec(command) { + try { + return execSync(command, { encoding: 'utf-8' }).trim(); + } catch (error) { + console.error(`Error executing command: ${command}`); + console.error(error.message); + return ''; + } +} + +/** + * Write output to GitHub Actions output file + * @param {string} name - Output name + * @param {string} value - Output value + */ +function setOutput(name, value) { + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + appendFileSync(outputFile, `${name}=${value}\n`); + } + console.log(`${name}=${value}`); +} + +/** + * Get the list of changed files between two commits + * @returns {string[]} Array of changed file paths + */ +function getChangedFiles() { + const eventName = process.env.GITHUB_EVENT_NAME || 'local'; + + if (eventName === 'pull_request') { + const baseSha = process.env.GITHUB_BASE_SHA; + const headSha = process.env.GITHUB_HEAD_SHA; + + if (baseSha && headSha) { + console.log(`Comparing PR: ${baseSha}...${headSha}`); + try { + // Ensure we have the base commit + try { + execSync(`git cat-file -e ${baseSha}`, { stdio: 'ignore' }); + } catch { + console.log('Base commit not available locally, attempting fetch...'); + execSync(`git fetch origin ${baseSha}`, { stdio: 'inherit' }); + } + const output = exec(`git diff --name-only ${baseSha} ${headSha}`); + return output ? output.split('\n').filter(Boolean) : []; + } catch (error) { + console.error(`Git diff failed: ${error.message}`); + } + } + } + + // For push events or fallback + console.log('Comparing HEAD^ to HEAD'); + try { + const output = exec('git diff --name-only HEAD^ HEAD'); + return output ? output.split('\n').filter(Boolean) : []; + } catch { + // If HEAD^ doesn't exist (first commit), list all files in HEAD + console.log('HEAD^ not available, listing all files in HEAD'); + const output = exec('git ls-tree --name-only -r HEAD'); + return output ? output.split('\n').filter(Boolean) : []; + } +} + +/** + * Check if a file should be excluded from code changes detection + * @param {string} filePath - The file path to check + * @returns {boolean} True if the file should be excluded + */ +function isExcludedFromCodeChanges(filePath) { + // Exclude markdown files in any folder + if (filePath.endsWith('.md')) { + return true; + } + + // Exclude specific folders from code changes + const excludedFolders = ['.changeset/', 'docs/', 'experiments/', 'examples/']; + + for (const folder of excludedFolders) { + if (filePath.startsWith(folder)) { + return true; + } + } + + return false; +} + +/** + * Main function to detect changes + */ +function detectChanges() { + console.log('Detecting file changes for CI/CD...\n'); + + const changedFiles = getChangedFiles(); + + console.log('Changed files:'); + if (changedFiles.length === 0) { + console.log(' (none)'); + } else { + changedFiles.forEach((file) => console.log(` ${file}`)); + } + console.log(''); + + // Detect .mjs file changes + const mjsChanged = changedFiles.some((file) => file.endsWith('.mjs')); + setOutput('mjs-changed', mjsChanged ? 'true' : 'false'); + + // Detect .js file changes + const jsChanged = changedFiles.some((file) => file.endsWith('.js')); + setOutput('js-changed', jsChanged ? 'true' : 'false'); + + // Detect package.json changes + const packageChanged = changedFiles.some((file) => file === 'package.json'); + setOutput('package-changed', packageChanged ? 'true' : 'false'); + + // Detect documentation changes (any .md file) + const docsChanged = changedFiles.some((file) => file.endsWith('.md')); + setOutput('docs-changed', docsChanged ? 'true' : 'false'); + + // Detect workflow changes + const workflowChanged = changedFiles.some((file) => + file.startsWith('.github/workflows/') + ); + setOutput('workflow-changed', workflowChanged ? 'true' : 'false'); + + // Detect code changes (excluding docs, changesets, experiments, examples folders, and markdown files) + const codeChangedFiles = changedFiles.filter( + (file) => !isExcludedFromCodeChanges(file) + ); + + console.log('\nFiles considered as code changes:'); + if (codeChangedFiles.length === 0) { + console.log(' (none)'); + } else { + codeChangedFiles.forEach((file) => console.log(` ${file}`)); + } + console.log(''); + + // Check if any code files changed (.mjs, .js, .json, .yml, .yaml, or workflow files) + const codePattern = /\.(mjs|js|json|yml|yaml)$|\.github\/workflows\//; + const codeChanged = codeChangedFiles.some((file) => codePattern.test(file)); + setOutput('any-code-changed', codeChanged ? 'true' : 'false'); + + console.log('\nChange detection completed.'); +} + +// Run the detection +detectChanges(); diff --git a/js/scripts/merge-changesets.mjs b/js/scripts/merge-changesets.mjs new file mode 100644 index 0000000..7094804 --- /dev/null +++ b/js/scripts/merge-changesets.mjs @@ -0,0 +1,265 @@ +#!/usr/bin/env node + +/** + * Merge multiple changeset files into a single changeset + * + * Key behavior: + * - Combines all pending changesets into a single changeset file + * - Uses the highest version bump type (major > minor > patch) + * - Preserves all descriptions in chronological order (by file modification time) + * - Removes the individual changeset files after merging + * - Does nothing if there's only one or no changesets + * + * This script is run before `changeset version` to ensure a clean release + * even when multiple PRs have merged before a release cycle. + * + * Package name is read from package.json automatically. + */ + +import { + readdirSync, + readFileSync, + writeFileSync, + unlinkSync, + statSync, +} from 'fs'; +import { join } from 'path'; + +// Read package name from package.json +const packageJson = JSON.parse(readFileSync('package.json', 'utf-8')); +const PACKAGE_NAME = packageJson.name; +const CHANGESET_DIR = '.changeset'; + +// Version bump type priority (higher number = higher priority) +const BUMP_PRIORITY = { + patch: 1, + minor: 2, + major: 3, +}; + +/** + * Generate a random changeset file name (similar to what @changesets/cli does) + * @returns {string} + */ +function generateChangesetName() { + const adjectives = [ + 'bright', + 'calm', + 'cool', + 'cyan', + 'dark', + 'fast', + 'gold', + 'good', + 'green', + 'happy', + 'kind', + 'loud', + 'neat', + 'nice', + 'pink', + 'proud', + 'quick', + 'red', + 'rich', + 'safe', + 'shy', + 'soft', + 'sweet', + 'tall', + 'warm', + 'wise', + 'young', + ]; + const nouns = [ + 'apple', + 'bird', + 'book', + 'car', + 'cat', + 'cloud', + 'desk', + 'dog', + 'door', + 'fish', + 'flower', + 'frog', + 'grass', + 'house', + 'key', + 'lake', + 'leaf', + 'moon', + 'mouse', + 'owl', + 'park', + 'rain', + 'river', + 'rock', + 'sea', + 'star', + 'sun', + 'tree', + 'wave', + 'wind', + ]; + + const randomAdjective = + adjectives[Math.floor(Math.random() * adjectives.length)]; + const randomNoun = nouns[Math.floor(Math.random() * nouns.length)]; + + return `${randomAdjective}-${randomNoun}`; +} + +/** + * Parse a changeset file and extract its metadata + * @param {string} filePath + * @returns {{type: string, description: string, mtime: Date} | null} + */ +function parseChangeset(filePath) { + try { + const content = readFileSync(filePath, 'utf-8'); + const stats = statSync(filePath); + + // Extract version type - support both quoted and unquoted package names + const versionTypeRegex = new RegExp( + `^['"]?${PACKAGE_NAME.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]?:\\s+(major|minor|patch)`, + 'm' + ); + const versionTypeMatch = content.match(versionTypeRegex); + + if (!versionTypeMatch) { + console.warn( + `Warning: Could not parse version type from ${filePath}, skipping` + ); + return null; + } + + // Extract description + const parts = content.split('---'); + const description = + parts.length >= 3 ? parts.slice(2).join('---').trim() : ''; + + return { + type: versionTypeMatch[1], + description, + mtime: stats.mtime, + }; + } catch (error) { + console.warn(`Warning: Failed to parse ${filePath}: ${error.message}`); + return null; + } +} + +/** + * Get the highest priority bump type + * @param {string[]} types + * @returns {string} + */ +function getHighestBumpType(types) { + let highest = 'patch'; + for (const type of types) { + if (BUMP_PRIORITY[type] > BUMP_PRIORITY[highest]) { + highest = type; + } + } + return highest; +} + +/** + * Create a merged changeset file + * @param {string} type + * @param {string[]} descriptions + * @returns {string} + */ +function createMergedChangeset(type, descriptions) { + const combinedDescription = descriptions.join('\n\n'); + + return `--- +'${PACKAGE_NAME}': ${type} +--- + +${combinedDescription} +`; +} + +function main() { + console.log('Checking for multiple changesets to merge...'); + console.log(`Package name: ${PACKAGE_NAME}`); + + // Get all changeset files + const changesetFiles = readdirSync(CHANGESET_DIR).filter( + (file) => file.endsWith('.md') && file !== 'README.md' + ); + + console.log(`Found ${changesetFiles.length} changeset file(s)`); + + // If 0 or 1 changesets, nothing to merge + if (changesetFiles.length <= 1) { + console.log('No merging needed (0 or 1 changeset found)'); + return; + } + + console.log('Multiple changesets found, merging...'); + changesetFiles.forEach((file) => console.log(` - ${file}`)); + + // Parse all changesets + const parsedChangesets = []; + for (const file of changesetFiles) { + const filePath = join(CHANGESET_DIR, file); + const parsed = parseChangeset(filePath); + if (parsed) { + parsedChangesets.push({ + file, + filePath, + ...parsed, + }); + } + } + + if (parsedChangesets.length === 0) { + console.error('Error: No valid changesets could be parsed'); + process.exit(1); + } + + // Sort by modification time (oldest first) to preserve chronological order + parsedChangesets.sort((a, b) => a.mtime.getTime() - b.mtime.getTime()); + + // Determine the highest bump type + const bumpTypes = parsedChangesets.map((c) => c.type); + const highestBumpType = getHighestBumpType(bumpTypes); + + console.log(`\nMerge summary:`); + console.log(` Bump types found: ${[...new Set(bumpTypes)].join(', ')}`); + console.log(` Using highest: ${highestBumpType}`); + + // Collect descriptions in chronological order + const descriptions = parsedChangesets + .filter((c) => c.description) + .map((c) => c.description); + + console.log(` Descriptions to merge: ${descriptions.length}`); + + // Create merged changeset content + const mergedContent = createMergedChangeset(highestBumpType, descriptions); + + // Generate a unique name for the merged changeset + const mergedFileName = `merged-${generateChangesetName()}.md`; + const mergedFilePath = join(CHANGESET_DIR, mergedFileName); + + // Write the merged changeset + writeFileSync(mergedFilePath, mergedContent); + console.log(`\nCreated merged changeset: ${mergedFileName}`); + + // Remove the original changeset files + console.log('\nRemoving original changeset files:'); + for (const changeset of parsedChangesets) { + unlinkSync(changeset.filePath); + console.log(` Removed: ${changeset.file}`); + } + + console.log('\nChangeset merge completed successfully'); + console.log(`\nMerged changeset content:\n${mergedContent}`); +} + +main(); diff --git a/rust/changelog.d/20260108_193000_ci_cd_improvements.md b/rust/changelog.d/20260108_193000_ci_cd_improvements.md new file mode 100644 index 0000000..e8ec1c2 --- /dev/null +++ b/rust/changelog.d/20260108_193000_ci_cd_improvements.md @@ -0,0 +1,13 @@ +--- +bump: patch +--- + +Add CI/CD improvements based on template best practices: + +- Add detect-code-changes.mjs for smart change detection +- Add check-version-modification.mjs to prevent manual Cargo.toml version changes +- Add check-changelog-fragment.mjs for PR-diff-based changelog fragment checking +- Add check-file-size.mjs for Rust file line limit checking +- Add git-config.mjs for CI git configuration +- Update workflow with detect-changes job, version check, file size check, + and improved concurrency configuration diff --git a/rust/scripts/check-changelog-fragment.mjs b/rust/scripts/check-changelog-fragment.mjs new file mode 100644 index 0000000..7f11344 --- /dev/null +++ b/rust/scripts/check-changelog-fragment.mjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node + +/** + * 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: node scripts/check-changelog-fragment.mjs + * + * 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) + */ + +import { execSync } from 'child_process'; + +/** + * Execute a shell command and return trimmed output + * @param {string} command - The command to execute + * @returns {string} - The trimmed command output + */ +function exec(command) { + try { + return execSync(command, { encoding: 'utf-8' }).trim(); + } catch (error) { + console.error(`Error executing command: ${command}`); + console.error(error.message); + return ''; + } +} + +/** + * Get the list of changed files in the PR + * @returns {string[]} Array of changed file paths + */ +function getChangedFiles() { + const baseRef = process.env.GITHUB_BASE_REF || 'main'; + console.log(`Comparing against origin/${baseRef}...HEAD`); + + try { + const output = exec(`git diff --name-only origin/${baseRef}...HEAD`); + return output ? output.split('\n').filter(Boolean) : []; + } catch (error) { + console.error(`Git diff failed: ${error.message}`); + return []; + } +} + +/** + * Check if a file is a source file that requires a changelog fragment + * @param {string} filePath - The file path to check + * @returns {boolean} True if the file is a source file + */ +function isSourceFile(filePath) { + // Source files that require changelog fragments + const sourcePatterns = [ + /^src\//, + /^tests\//, + /^scripts\//, + /^Cargo\.toml$/, + ]; + + return sourcePatterns.some((pattern) => pattern.test(filePath)); +} + +/** + * Check if a file is a changelog fragment + * @param {string} filePath - The file path to check + * @returns {boolean} True if the file is a changelog fragment + */ +function isChangelogFragment(filePath) { + // Changelog fragments are .md files in changelog.d/ (excluding README.md) + return ( + filePath.startsWith('changelog.d/') && + filePath.endsWith('.md') && + !filePath.endsWith('README.md') + ); +} + +/** + * Main function to check changelog fragments + */ +function checkChangelogFragment() { + console.log('Checking for changelog fragment in PR diff...\n'); + + const changedFiles = getChangedFiles(); + + if (changedFiles.length === 0) { + console.log('No changed files found'); + process.exit(0); + } + + console.log('Changed files:'); + changedFiles.forEach((file) => console.log(` ${file}`)); + console.log(''); + + // Count source files changed + const sourceChanges = changedFiles.filter(isSourceFile); + const sourceChangedCount = sourceChanges.length; + + console.log(`Source files changed: ${sourceChangedCount}`); + if (sourceChangedCount > 0) { + sourceChanges.forEach((file) => console.log(` ${file}`)); + } + console.log(''); + + // Count changelog fragments added in this PR + const fragmentsAdded = changedFiles.filter(isChangelogFragment); + const fragmentAddedCount = fragmentsAdded.length; + + console.log(`Changelog fragments added: ${fragmentAddedCount}`); + if (fragmentAddedCount > 0) { + fragmentsAdded.forEach((file) => console.log(` ${file}`)); + } + console.log(''); + + // Check if source files changed but no fragment was added + if (sourceChangedCount > 0 && fragmentAddedCount === 0) { + console.error( + '::error::No changelog fragment found in this PR. Please add a changelog entry in changelog.d/' + ); + console.error(''); + console.error('To create a changelog fragment:'); + console.error( + ' Create a new .md file in changelog.d/ with your changes' + ); + console.error(''); + console.error('See changelog.d/README.md for more information.'); + process.exit(1); + } + + console.log( + `Changelog check passed (source files changed: ${sourceChangedCount}, fragments added: ${fragmentAddedCount})` + ); +} + +// Run the check +checkChangelogFragment(); diff --git a/rust/scripts/check-file-size.mjs b/rust/scripts/check-file-size.mjs new file mode 100644 index 0000000..56bdbd3 --- /dev/null +++ b/rust/scripts/check-file-size.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node + +/** + * Check for files exceeding the maximum allowed line count + * Exits with error code 1 if any files exceed the limit + */ + +import { readFileSync, readdirSync, statSync } from 'fs'; +import { join, relative, extname } from 'path'; + +const MAX_LINES = 2000; +const FILE_EXTENSIONS = ['.rs']; +const EXCLUDE_PATTERNS = ['target', '.git', 'node_modules']; + +/** + * Check if a path should be excluded + * @param {string} path + * @returns {boolean} + */ +function shouldExclude(path) { + return EXCLUDE_PATTERNS.some((pattern) => path.includes(pattern)); +} + +/** + * Recursively find all Rust files in a directory + * @param {string} directory + * @returns {string[]} + */ +function findRustFiles(directory) { + const files = []; + + function walkDir(dir) { + const entries = readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + + if (shouldExclude(fullPath)) { + continue; + } + + if (entry.isDirectory()) { + walkDir(fullPath); + } else if (entry.isFile() && FILE_EXTENSIONS.includes(extname(entry.name))) { + files.push(fullPath); + } + } + } + + walkDir(directory); + return files; +} + +/** + * Count lines in a file + * @param {string} filePath + * @returns {number} + */ +function countLines(filePath) { + const content = readFileSync(filePath, 'utf-8'); + return content.split('\n').length; +} + +try { + const cwd = process.cwd(); + console.log(`\nChecking Rust files for maximum ${MAX_LINES} lines...\n`); + + const files = findRustFiles(cwd); + const violations = []; + + for (const file of files) { + const lineCount = countLines(file); + if (lineCount > MAX_LINES) { + violations.push({ + file: relative(cwd, file), + lines: lineCount, + }); + } + } + + if (violations.length === 0) { + console.log('All files are within the line limit\n'); + process.exit(0); + } else { + console.log('Found files exceeding the line limit:\n'); + for (const violation of violations) { + console.log( + ` ${violation.file}: ${violation.lines} lines (exceeds ${MAX_LINES})` + ); + } + console.log(`\nPlease refactor these files to be under ${MAX_LINES} lines\n`); + process.exit(1); + } +} catch (error) { + console.error('Error:', error.message); + process.exit(1); +} diff --git a/rust/scripts/check-version-modification.mjs b/rust/scripts/check-version-modification.mjs new file mode 100644 index 0000000..34cfadf --- /dev/null +++ b/rust/scripts/check-version-modification.mjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node + +/** + * 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: + * node scripts/check-version-modification.mjs + * + * 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 + */ + +import { execSync } from 'child_process'; + +/** + * Execute a shell command and return trimmed output + * @param {string} command - The command to execute + * @returns {string} - The trimmed command output + */ +function exec(command) { + try { + return execSync(command, { encoding: 'utf-8' }).trim(); + } catch (error) { + // Return empty string for commands that fail (like git diff with no changes) + return ''; + } +} + +/** + * Check if the current branch should skip version checking + * @returns {boolean} True if version check should be skipped + */ +function shouldSkipVersionCheck() { + const headRef = process.env.GITHUB_HEAD_REF || ''; + + // Skip for automated release PRs + const automatedBranchPrefixes = [ + 'changelog-manual-release-', + 'changeset-release/', + 'release/', + 'automated-release/', + ]; + + for (const prefix of automatedBranchPrefixes) { + if (headRef.startsWith(prefix)) { + console.log(`Skipping version check for automated branch: ${headRef}`); + return true; + } + } + + return false; +} + +/** + * Get the diff for Cargo.toml between base and head + * @returns {string} The diff output + */ +function getCargoTomlDiff() { + const baseRef = process.env.GITHUB_BASE_REF || 'main'; + + // Ensure we have the base branch + try { + execSync(`git fetch origin ${baseRef} --depth=1`, { stdio: 'ignore' }); + } catch { + // Ignore fetch errors - base might already be available + } + + // Get the diff for Cargo.toml + const diff = exec(`git diff origin/${baseRef}...HEAD -- Cargo.toml`); + return diff; +} + +/** + * Check if the diff contains version line changes + * @param {string} diff - The git diff output + * @returns {boolean} True if version was modified + */ +function hasVersionChange(diff) { + if (!diff) { + return false; + } + + // Look for changes to the version line + // Match lines that start with + or - followed by version = "..." + const versionChangePattern = /^[+-]version\s*=\s*"/m; + return versionChangePattern.test(diff); +} + +/** + * Main function + */ +function main() { + console.log('Checking for manual version modifications in Cargo.toml...\n'); + + // Only run on pull requests + const eventName = process.env.GITHUB_EVENT_NAME || ''; + if (eventName !== 'pull_request') { + console.log(`Skipping: Not a pull request event (event: ${eventName})`); + process.exit(0); + } + + // Skip for automated release branches + if (shouldSkipVersionCheck()) { + process.exit(0); + } + + // Get and check the diff + const diff = getCargoTomlDiff(); + + if (!diff) { + console.log('No changes to Cargo.toml detected.'); + console.log('Version check passed.'); + process.exit(0); + } + + // Check for version changes + if (hasVersionChange(diff)) { + console.error('Error: Manual version change detected in Cargo.toml!\n'); + console.error('Versions are managed automatically by the CI/CD pipeline.'); + console.error('Please do not modify the version field directly.\n'); + console.error('To trigger a release, add a changelog fragment to changelog.d/'); + console.error('with the appropriate bump type (major, minor, or patch).\n'); + console.error('See changelog.d/README.md for more information.\n'); + console.error('If you need to undo your version change, run:'); + console.error(' git checkout origin/main -- Cargo.toml'); + process.exit(1); + } + + console.log('Cargo.toml was modified but version field was not changed.'); + console.log('Version check passed.'); + process.exit(0); +} + +// Run the check +main(); diff --git a/rust/scripts/detect-code-changes.mjs b/rust/scripts/detect-code-changes.mjs new file mode 100644 index 0000000..065c6ed --- /dev/null +++ b/rust/scripts/detect-code-changes.mjs @@ -0,0 +1,194 @@ +#!/usr/bin/env node + +/** + * 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: + * node scripts/detect-code-changes.mjs + * + * 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) + */ + +import { execSync } from 'child_process'; +import { appendFileSync } from 'fs'; + +/** + * Execute a shell command and return trimmed output + * @param {string} command - The command to execute + * @returns {string} - The trimmed command output + */ +function exec(command) { + try { + return execSync(command, { encoding: 'utf-8' }).trim(); + } catch (error) { + console.error(`Error executing command: ${command}`); + console.error(error.message); + return ''; + } +} + +/** + * Write output to GitHub Actions output file + * @param {string} name - Output name + * @param {string} value - Output value + */ +function setOutput(name, value) { + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + appendFileSync(outputFile, `${name}=${value}\n`); + } + console.log(`${name}=${value}`); +} + +/** + * Get the list of changed files between two commits + * @returns {string[]} Array of changed file paths + */ +function getChangedFiles() { + const eventName = process.env.GITHUB_EVENT_NAME || 'local'; + + if (eventName === 'pull_request') { + const baseSha = process.env.GITHUB_BASE_SHA; + const headSha = process.env.GITHUB_HEAD_SHA; + + if (baseSha && headSha) { + console.log(`Comparing PR: ${baseSha}...${headSha}`); + try { + // Ensure we have the base commit + try { + execSync(`git cat-file -e ${baseSha}`, { stdio: 'ignore' }); + } catch { + console.log('Base commit not available locally, attempting fetch...'); + execSync(`git fetch origin ${baseSha}`, { stdio: 'inherit' }); + } + const output = exec(`git diff --name-only ${baseSha} ${headSha}`); + return output ? output.split('\n').filter(Boolean) : []; + } catch (error) { + console.error(`Git diff failed: ${error.message}`); + } + } + } + + // For push events or fallback + console.log('Comparing HEAD^ to HEAD'); + try { + const output = exec('git diff --name-only HEAD^ HEAD'); + return output ? output.split('\n').filter(Boolean) : []; + } catch { + // If HEAD^ doesn't exist (first commit), list all files in HEAD + console.log('HEAD^ not available, listing all files in HEAD'); + const output = exec('git ls-tree --name-only -r HEAD'); + return output ? output.split('\n').filter(Boolean) : []; + } +} + +/** + * Check if a file should be excluded from code changes detection + * @param {string} filePath - The file path to check + * @returns {boolean} True if the file should be excluded + */ +function isExcludedFromCodeChanges(filePath) { + // Exclude markdown files in any folder + if (filePath.endsWith('.md')) { + return true; + } + + // Exclude specific folders from code changes + const excludedFolders = ['changelog.d/', 'docs/', 'experiments/', 'examples/']; + + for (const folder of excludedFolders) { + if (filePath.startsWith(folder)) { + return true; + } + } + + return false; +} + +/** + * Main function to detect changes + */ +function detectChanges() { + console.log('Detecting file changes for CI/CD...\n'); + + const changedFiles = getChangedFiles(); + + console.log('Changed files:'); + if (changedFiles.length === 0) { + console.log(' (none)'); + } else { + changedFiles.forEach((file) => console.log(` ${file}`)); + } + console.log(''); + + // Detect .rs file changes (Rust source) + const rsChanged = changedFiles.some((file) => file.endsWith('.rs')); + setOutput('rs-changed', rsChanged ? 'true' : 'false'); + + // Detect .toml file changes (Cargo.toml, Cargo.lock, etc.) + const tomlChanged = changedFiles.some((file) => file.endsWith('.toml')); + setOutput('toml-changed', tomlChanged ? 'true' : 'false'); + + // Detect .mjs file changes (scripts) + const mjsChanged = changedFiles.some((file) => file.endsWith('.mjs')); + setOutput('mjs-changed', mjsChanged ? 'true' : 'false'); + + // Detect documentation changes (any .md file) + const docsChanged = changedFiles.some((file) => file.endsWith('.md')); + setOutput('docs-changed', docsChanged ? 'true' : 'false'); + + // Detect workflow changes + const workflowChanged = changedFiles.some((file) => + file.startsWith('.github/workflows/') + ); + setOutput('workflow-changed', workflowChanged ? 'true' : 'false'); + + // Detect code changes (excluding docs, changelog.d, experiments, examples folders, and markdown files) + const codeChangedFiles = changedFiles.filter( + (file) => !isExcludedFromCodeChanges(file) + ); + + console.log('\nFiles considered as code changes:'); + if (codeChangedFiles.length === 0) { + console.log(' (none)'); + } else { + codeChangedFiles.forEach((file) => console.log(` ${file}`)); + } + console.log(''); + + // Check if any code files changed (.rs, .toml, .mjs, .yml, .yaml, or workflow files) + const codePattern = /\.(rs|toml|mjs|js|yml|yaml)$|\.github\/workflows\//; + const codeChanged = codeChangedFiles.some((file) => codePattern.test(file)); + setOutput('any-code-changed', codeChanged ? 'true' : 'false'); + + console.log('\nChange detection completed.'); +} + +// Run the detection +detectChanges(); diff --git a/rust/scripts/git-config.mjs b/rust/scripts/git-config.mjs new file mode 100644 index 0000000..e7b6bca --- /dev/null +++ b/rust/scripts/git-config.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node + +/** + * 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: node scripts/git-config.mjs [--name ] [--email ] + */ + +import { execSync } from 'child_process'; + +// Default values +const name = process.env.GIT_USER_NAME || 'github-actions[bot]'; +const email = process.env.GIT_USER_EMAIL || 'github-actions[bot]@users.noreply.github.com'; + +/** + * Execute a shell command + * @param {string} command - The command to execute + */ +function exec(command) { + try { + execSync(command, { stdio: 'inherit' }); + } catch (error) { + console.error(`Error executing command: ${command}`); + console.error(error.message); + process.exit(1); + } +} + +try { + console.log(`Configuring git user: ${name} <${email}>`); + + exec(`git config user.name "${name}"`); + exec(`git config user.email "${email}"`); + + console.log('Git configuration complete'); +} catch (error) { + console.error('Error configuring git:', error.message); + process.exit(1); +}