fix(ci): release-format consistency and npm publish verification #33
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Rust CI/CD | |
| on: | |
| push: | |
| branches: | |
| - main | |
| paths: | |
| - 'rust/**' | |
| - '.github/workflows/rust.yml' | |
| pull_request: | |
| types: [opened, synchronize, reopened] | |
| paths: | |
| - 'rust/**' | |
| - '.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 | |
| type: choice | |
| options: | |
| - patch | |
| - minor | |
| - major | |
| description: | |
| description: 'Release description (optional)' | |
| required: false | |
| type: string | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| CARGO_TERM_COLOR: always | |
| RUSTFLAGS: -Dwarnings | |
| jobs: | |
| # === 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 | |
| - name: Set up Rust | |
| uses: dtolnay/rust-toolchain@stable | |
| 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: | |
| path: | | |
| ~/.cargo/bin/ | |
| ~/.cargo/registry/index/ | |
| ~/.cargo/registry/cache/ | |
| ~/.cargo/git/db/ | |
| rust/target/ | |
| key: ${{ runner.os }}-cargo-${{ hashFiles('rust/Cargo.lock') }} | |
| - name: Check formatting | |
| working-directory: ./rust | |
| run: cargo fmt --check | |
| - name: Run clippy | |
| working-directory: ./rust | |
| run: cargo clippy --all-targets --all-features | |
| - name: Check file size limit | |
| working-directory: ./rust | |
| run: node scripts/check-file-size.mjs | |
| - name: Run CI script tests | |
| working-directory: ./rust | |
| run: node --test scripts/*.test.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: | |
| os: [ubuntu-latest, macos-latest, windows-latest] | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Set up Rust | |
| uses: dtolnay/rust-toolchain@stable | |
| - name: Cache cargo dependencies | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/.cargo/bin/ | |
| ~/.cargo/registry/index/ | |
| ~/.cargo/registry/cache/ | |
| ~/.cargo/git/db/ | |
| rust/target/ | |
| key: ${{ runner.os }}-cargo-${{ hashFiles('rust/Cargo.lock') }} | |
| - name: Run tests | |
| 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 | |
| - name: Set up Rust | |
| uses: dtolnay/rust-toolchain@stable | |
| - name: Cache cargo dependencies | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/.cargo/bin/ | |
| ~/.cargo/registry/index/ | |
| ~/.cargo/registry/cache/ | |
| ~/.cargo/git/db/ | |
| rust/target/ | |
| key: ${{ runner.os }}-cargo-build-${{ hashFiles('rust/Cargo.lock') }} | |
| - name: Build release | |
| working-directory: ./rust | |
| run: cargo build --release | |
| - name: Package crate | |
| working-directory: ./rust | |
| run: cargo package --list | |
| # === AUTO RELEASE === | |
| # Automatic release on push to main using changelog fragments | |
| auto-release: | |
| name: Auto Release | |
| needs: [lint, test, build] | |
| if: | | |
| always() && !cancelled() && | |
| github.event_name == 'push' && | |
| github.ref == 'refs/heads/main' && | |
| needs.build.result == 'success' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Set up Rust | |
| uses: dtolnay/rust-toolchain@stable | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '22' | |
| - 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 release is needed | |
| id: release_check | |
| working-directory: ./rust | |
| env: | |
| HAS_FRAGMENTS: ${{ steps.bump_type.outputs.has_fragments }} | |
| run: node scripts/check-release-needed.mjs | |
| - name: Collect changelog and bump version | |
| id: version | |
| if: steps.release_check.outputs.should_release == 'true' && steps.release_check.outputs.skip_bump != '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.release_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.release_check.outputs.should_release == 'true' | |
| working-directory: ./rust | |
| run: cargo build --release | |
| - name: Publish to crates.io | |
| if: steps.release_check.outputs.should_release == 'true' | |
| working-directory: ./rust | |
| env: | |
| CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }} | |
| run: | | |
| if [ -z "$CARGO_REGISTRY_TOKEN" ]; then | |
| echo "::error title=CARGO_REGISTRY_TOKEN missing::Cannot publish to crates.io without an API token." | |
| echo "" | |
| echo "How to fix:" | |
| echo " 1. Generate a token at https://crates.io/me (Account Settings -> API Tokens)." | |
| echo " Scope: 'publish-update' for this crate." | |
| echo " 2. Add it to this repo at:" | |
| echo " Settings -> Secrets and variables -> Actions -> New repository secret" | |
| echo " Name: CARGO_REGISTRY_TOKEN (CARGO_TOKEN is also accepted as a fallback)" | |
| echo " 3. Re-run this workflow." | |
| echo "" | |
| echo "See docs/case-studies/issue-29/README.md (credentials) and" | |
| echo " docs/case-studies/issue-25/README.md (publishing pipeline)." | |
| exit 1 | |
| fi | |
| # `cargo publish` exits non-zero on retry if the version already exists; we tolerate that | |
| # because the registry probe in release_check already proved the version is missing, so a | |
| # late "already exists" error means a parallel run won the race -- treat as success. | |
| if ! OUT=$(cargo publish 2>&1); then | |
| echo "$OUT" | |
| if echo "$OUT" | grep -qE 'already (uploaded|exists)'; then | |
| echo "::warning::Version was published by another run between probe and publish; treating as success." | |
| exit 0 | |
| fi | |
| if echo "$OUT" | grep -qiE 'unauthorized|forbidden|403|401|invalid token|token (expired|rejected)'; then | |
| echo "::error title=crates.io credentials rejected::crates.io rejected CARGO_REGISTRY_TOKEN." | |
| echo "" | |
| echo "How to fix:" | |
| echo " 1. The token may have expired or been revoked. Rotate it at" | |
| echo " https://crates.io/me (Account Settings -> API Tokens)" | |
| echo " 2. Update the secret at:" | |
| echo " Settings -> Secrets and variables -> Actions -> CARGO_REGISTRY_TOKEN" | |
| echo " 3. Verify the token's scope includes 'publish-update' for this crate." | |
| echo "" | |
| echo "See docs/case-studies/issue-29/README.md." | |
| fi | |
| exit 1 | |
| fi | |
| echo "$OUT" | |
| - name: Create GitHub Release | |
| if: steps.release_check.outputs.should_release == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| working-directory: ./rust | |
| run: | | |
| node scripts/create-github-release.mjs \ | |
| --version "${{ steps.current_version.outputs.version }}" \ | |
| --repository "${{ github.repository }}" \ | |
| --tag-prefix "rust_v" \ | |
| --language "Rust" | |
| # === MANUAL INSTANT RELEASE === | |
| # Manual release via workflow_dispatch - only after CI passes | |
| manual-release: | |
| name: Instant Release | |
| needs: [lint, test, build] | |
| if: | | |
| always() && !cancelled() && | |
| github.event_name == 'workflow_dispatch' && | |
| github.event.inputs.release_mode == 'instant' && | |
| needs.build.result == 'success' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Set up Rust | |
| uses: dtolnay/rust-toolchain@stable | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '22' | |
| - name: Configure git | |
| 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 | |
| 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' | |
| working-directory: ./rust | |
| run: cargo build --release | |
| - name: Publish to crates.io | |
| if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' | |
| working-directory: ./rust | |
| env: | |
| CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }} | |
| run: | | |
| if [ -z "$CARGO_REGISTRY_TOKEN" ]; then | |
| echo "::error title=CARGO_REGISTRY_TOKEN missing::Cannot publish to crates.io without an API token." | |
| echo "" | |
| echo "How to fix:" | |
| echo " 1. Generate a token at https://crates.io/me (Account Settings -> API Tokens)." | |
| echo " Scope: 'publish-update' for this crate." | |
| echo " 2. Add it to this repo at:" | |
| echo " Settings -> Secrets and variables -> Actions -> New repository secret" | |
| echo " Name: CARGO_REGISTRY_TOKEN (CARGO_TOKEN is also accepted as a fallback)" | |
| echo " 3. Re-run this workflow." | |
| echo "" | |
| echo "See docs/case-studies/issue-29/README.md (credentials) and" | |
| echo " docs/case-studies/issue-25/README.md (publishing pipeline)." | |
| exit 1 | |
| fi | |
| if ! OUT=$(cargo publish 2>&1); then | |
| echo "$OUT" | |
| if echo "$OUT" | grep -qE 'already (uploaded|exists)'; then | |
| echo "::warning::Version is already on crates.io; treating manual re-run as success." | |
| exit 0 | |
| fi | |
| if echo "$OUT" | grep -qiE 'unauthorized|forbidden|403|401|invalid token|token (expired|rejected)'; then | |
| echo "::error title=crates.io credentials rejected::crates.io rejected CARGO_REGISTRY_TOKEN." | |
| echo "" | |
| echo "How to fix:" | |
| echo " 1. The token may have expired or been revoked. Rotate it at" | |
| echo " https://crates.io/me (Account Settings -> API Tokens)" | |
| echo " 2. Update the secret at:" | |
| echo " Settings -> Secrets and variables -> Actions -> CARGO_REGISTRY_TOKEN" | |
| echo " 3. Verify the token's scope includes 'publish-update' for this crate." | |
| echo "" | |
| echo "See docs/case-studies/issue-29/README.md." | |
| fi | |
| exit 1 | |
| fi | |
| echo "$OUT" | |
| - name: Create GitHub Release | |
| if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| working-directory: ./rust | |
| run: | | |
| node scripts/create-github-release.mjs \ | |
| --version "${{ steps.version.outputs.new_version }}" \ | |
| --repository "${{ github.repository }}" \ | |
| --tag-prefix "rust_v" \ | |
| --language "Rust" | |
| # === 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 |