diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a91b435..2f6cd7f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,14 @@ on: types: [opened, synchronize, reopened] workflow_dispatch: inputs: + release_mode: + description: 'Manual release mode' + required: true + type: choice + default: 'instant' + options: + - instant + - changelog-pr bump_type: description: 'Version bump type' required: true @@ -28,15 +36,94 @@ concurrency: env: CARGO_TERM_COLOR: always RUSTFLAGS: -Dwarnings + CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} jobs: - # REQUIRED CI CHECKS - All must pass before release - # These jobs ensure code quality and tests pass before any release + # === DETECT CHANGES - determines which jobs should run === + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + if: github.event_name != 'workflow_dispatch' + outputs: + rs-changed: ${{ steps.changes.outputs.rs-changed }} + toml-changed: ${{ steps.changes.outputs.toml-changed }} + 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: '20.x' + + - 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: 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: Check for changelog fragments + run: | + # Get list of fragment files (excluding README and template) + FRAGMENTS=$(find changelog.d -name "*.md" ! -name "README.md" 2>/dev/null | wc -l) - # Linting and formatting + # Get changed files in PR + CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) + + # Check if any source files changed (excluding docs and config) + SOURCE_CHANGED=$(echo "$CHANGED_FILES" | grep -E "^(src/|tests/|scripts/|Cargo\.toml)" | wc -l) + + if [ "$SOURCE_CHANGED" -gt 0 ] && [ "$FRAGMENTS" -eq 0 ]; then + echo "::error::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." + exit 1 + fi + + echo "Changelog check passed" + + # === LINT AND FORMAT CHECK === + # Lint runs independently of changelog check - it's a fast check that should always run + # See: https://github.com/link-assistant/hive-mind/pull/1024 for why this dependency was removed 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.mjs-changed == 'true' || + needs.detect-changes.outputs.docs-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' + ) steps: - uses: actions/checkout@v4 @@ -70,10 +157,14 @@ jobs: - name: Check file size limit 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: @@ -101,11 +192,13 @@ jobs: - name: Run doc tests run: cargo test --doc --verbose + # === 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 @@ -129,47 +222,18 @@ jobs: - name: Check package run: cargo package --list - # Check for changelog fragments in PRs - changelog: - name: Changelog Fragment Check - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Check for changelog fragments - run: | - # Get list of fragment files (excluding README and template) - FRAGMENTS=$(find changelog.d -name "*.md" ! -name "README.md" 2>/dev/null | wc -l) - - # Get changed files in PR - CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) - - # Check if any source files changed (excluding docs and config) - SOURCE_CHANGED=$(echo "$CHANGED_FILES" | grep -E "^(src/|tests/|scripts/|examples/)" | wc -l) - - if [ "$SOURCE_CHANGED" -gt 0 ] && [ "$FRAGMENTS" -eq 0 ]; then - echo "::warning::No changelog fragment found. Please add a changelog entry in changelog.d/" - echo "" - echo "To create a changelog fragment:" - echo " Create a new .md file in changelog.d/ with your changes" - echo "" - echo "See changelog.d/README.md for more information." - # Note: This is a warning, not a failure, to allow flexibility - # Change 'exit 0' to 'exit 1' to make it required - exit 0 - fi - - echo "Changelog check passed" - + # === 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] - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + # 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 @@ -235,20 +299,69 @@ jobs: if: steps.check.outputs.should_release == 'true' run: cargo build --release + - name: Publish to Crates.io + if: steps.check.outputs.should_release == 'true' + id: publish-crate + run: | + PACKAGE_NAME=$(grep '^name = ' Cargo.toml | head -1 | sed 's/name = "\(.*\)"/\1/') + PACKAGE_VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') + echo "Package: $PACKAGE_NAME@$PACKAGE_VERSION" + + echo "=== Attempting to publish to crates.io ===" + + # Try to publish and capture the result + set +e # Don't exit on error + cargo publish --token ${{ secrets.CARGO_TOKEN }} --allow-dirty 2>&1 | tee publish_output.txt + PUBLISH_EXIT_CODE=$? + set -e # Re-enable exit on error + + if [ $PUBLISH_EXIT_CODE -eq 0 ]; then + echo "Successfully published $PACKAGE_NAME@$PACKAGE_VERSION to crates.io" + echo "publish_result=success" >> $GITHUB_OUTPUT + elif grep -q "already uploaded" publish_output.txt || grep -q "already exists" publish_output.txt; then + echo "Version $PACKAGE_VERSION already exists on crates.io - this is OK" + echo "publish_result=already_exists" >> $GITHUB_OUTPUT + else + echo "Failed to publish for unknown reason" + cat publish_output.txt + echo "publish_result=failed" >> $GITHUB_OUTPUT + exit 1 + fi + + - name: Report crates.io publish status + if: steps.check.outputs.should_release == 'true' + run: | + if [ "${{ steps.publish-crate.outputs.publish_result }}" = "success" ]; then + echo "Package was successfully published to crates.io" + elif [ "${{ steps.publish-crate.outputs.publish_result }}" = "already_exists" ]; then + echo "Package version already exists on crates.io - no action needed" + else + echo "Publishing to crates.io failed - please check the logs" + fi + - name: Create GitHub Release if: steps.check.outputs.should_release == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + PACKAGE_NAME=$(grep '^name = ' Cargo.toml | head -1 | sed 's/name = "\(.*\)"/\1/') node scripts/create-github-release.mjs \ --release-version "${{ steps.current_version.outputs.version }}" \ - --repository "${{ github.repository }}" + --repository "${{ github.repository }}" \ + --crates-io-url "https://crates.io/crates/$PACKAGE_NAME" + # === 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' + # 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 @@ -293,11 +406,134 @@ jobs: if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' run: cargo build --release + - name: Publish to Crates.io + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + id: publish-crate + run: | + PACKAGE_NAME=$(grep '^name = ' Cargo.toml | head -1 | sed 's/name = "\(.*\)"/\1/') + PACKAGE_VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') + echo "Package: $PACKAGE_NAME@$PACKAGE_VERSION" + + echo "=== Attempting to publish to crates.io ===" + + # Try to publish and capture the result + set +e # Don't exit on error + cargo publish --token ${{ secrets.CARGO_TOKEN }} --allow-dirty 2>&1 | tee publish_output.txt + PUBLISH_EXIT_CODE=$? + set -e # Re-enable exit on error + + if [ $PUBLISH_EXIT_CODE -eq 0 ]; then + echo "Successfully published $PACKAGE_NAME@$PACKAGE_VERSION to crates.io" + echo "publish_result=success" >> $GITHUB_OUTPUT + elif grep -q "already uploaded" publish_output.txt || grep -q "already exists" publish_output.txt; then + echo "Version $PACKAGE_VERSION already exists on crates.io - this is OK" + echo "publish_result=already_exists" >> $GITHUB_OUTPUT + else + echo "Failed to publish for unknown reason" + cat publish_output.txt + echo "publish_result=failed" >> $GITHUB_OUTPUT + exit 1 + fi + + - name: Report crates.io publish status + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + run: | + if [ "${{ steps.publish-crate.outputs.publish_result }}" = "success" ]; then + echo "Package was successfully published to crates.io" + elif [ "${{ steps.publish-crate.outputs.publish_result }}" = "already_exists" ]; then + echo "Package version already exists on crates.io - no action needed" + else + echo "Publishing to crates.io failed - please check the logs" + fi + - name: Create GitHub Release if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + PACKAGE_NAME=$(grep '^name = ' Cargo.toml | head -1 | sed 's/name = "\(.*\)"/\1/') node scripts/create-github-release.mjs \ --release-version "${{ steps.version.outputs.new_version }}" \ - --repository "${{ github.repository }}" + --repository "${{ github.repository }}" \ + --crates-io-url "https://crates.io/crates/$PACKAGE_NAME" + + # === 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: '20.x' + + - name: Create changelog fragment + run: | + BUMP_TYPE="${{ github.event.inputs.bump_type }}" + DESCRIPTION="${{ github.event.inputs.description }}" + TIMESTAMP=$(date +%Y%m%d%H%M%S) + FRAGMENT_FILE="changelog.d/${TIMESTAMP}-manual-${BUMP_TYPE}.md" + + # Determine changelog category based on bump type + case "$BUMP_TYPE" in + major) + CATEGORY="### Breaking Changes" + ;; + minor) + CATEGORY="### Added" + ;; + patch) + CATEGORY="### Fixed" + ;; + esac + + # Create changelog fragment with frontmatter + mkdir -p changelog.d + cat > "$FRAGMENT_FILE" << EOF + --- + bump: $BUMP_TYPE + --- + + $CATEGORY + + EOF + + if [ -n "$DESCRIPTION" ]; then + echo "- ${DESCRIPTION}" >> "$FRAGMENT_FILE" + else + echo "- Manual ${BUMP_TYPE} release" >> "$FRAGMENT_FILE" + fi + + echo "Created changelog fragment: $FRAGMENT_FILE" + cat "$FRAGMENT_FILE" + + - 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/CONTRIBUTING.md b/CONTRIBUTING.md index 5823091..ab26b70 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contributing to rust-ai-driven-development-pipeline-template +# Contributing to trader-bot Thank you for your interest in contributing! This document provides guidelines and instructions for contributing to this project. @@ -7,8 +7,8 @@ Thank you for your interest in contributing! This document provides guidelines a 1. **Fork and clone the repository** ```bash - git clone https://github.com/YOUR-USERNAME/rust-ai-driven-development-pipeline-template.git - cd rust-ai-driven-development-pipeline-template + git clone https://github.com/YOUR-USERNAME/trader-bot.git + cd trader-bot ``` 2. **Install Rust** @@ -165,7 +165,7 @@ Use Rust documentation comments: /// # Examples /// /// ``` -/// use my_package::example_function; +/// use trader_bot::example_function; /// let result = example_function(1, 2); /// assert_eq!(result, 3); /// ``` diff --git a/changelog.d/20260108_022500_template_best_practices.md b/changelog.d/20260108_022500_template_best_practices.md new file mode 100644 index 0000000..46bf7ee --- /dev/null +++ b/changelog.d/20260108_022500_template_best_practices.md @@ -0,0 +1,10 @@ +### Changed + +- Updated CI/CD pipeline to match latest best practices from rust-ai-driven-development-pipeline-template +- Added detect-changes job for optimized CI workflow that skips unnecessary checks +- Added release_mode option (instant or changelog-pr) for manual releases +- Added changelog-pr job for creating PRs with changelog fragments +- Added crates.io publishing steps to auto-release and manual-release workflows +- Improved job dependencies using always() patterns for consistent behavior +- Updated changelog check to require fragments for code changes (exits with error instead of warning) +- Updated CONTRIBUTING.md with trader-bot specific references diff --git a/scripts/detect-code-changes.mjs b/scripts/detect-code-changes.mjs new file mode 100644 index 0000000..065c6ed --- /dev/null +++ b/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();