Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
368 changes: 232 additions & 136 deletions .github/workflows/rust.yml

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions changelog.d/20260321_220000_migrate_cicd_to_rust_scripts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
bump: minor
---

### Changed

- Migrated all CI/CD pipeline scripts from Node.js (.mjs) to Rust (.rs) using rust-script
- Updated workflow to use rust-script for all script execution, eliminating Node.js dependency

### Added

- Smart change detection job (`detect-code-changes.rs`) to skip unnecessary CI jobs
- Version modification check to prevent manual version edits in PRs
- Crates.io-based release check instead of git tag check (`check-release-needed.rs`)
- Graceful crates.io publish script with auth error handling (`publish-crate.rs`)
- Clippy lint check in CI pipeline
- Doc tests (`cargo test --doc`) in CI pipeline
- Package verification (`cargo package --list`) in CI build
- PR-diff-aware changelog fragment validation (`check-changelog-fragment.rs`)
- Changelog PR release mode for manual release workflow
- Multi-language repository path detection utility (`rust-paths.rs`)
- Case study documentation for issue #132

### Fixed

- CI/CD "Auto Release" failure caused by hard-failing on missing `CARGO_REGISTRY_TOKEN`
- Job dependency conditions now use `always() && !cancelled()` pattern for correct behavior
91 changes: 91 additions & 0 deletions docs/case-studies/issue-132/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Case Study: Issue #132 — CI/CD Missing Version Bump from Template

## Summary

The CI/CD pipeline in linksplatform/Numbers was failing at the "Auto Release" step
because it had diverged from the best practices established in the
[rust-ai-driven-development-pipeline-template](https://github.com/link-foundation/rust-ai-driven-development-pipeline-template).

## Timeline

- **2026-03-21T22:29:19Z**: CI/CD run [23390203130](https://github.com/linksplatform/Numbers/actions/runs/23390203130) triggered on push to main
- **2026-03-21T22:30:51Z**: "Auto Release" job failed at "Publish to Crates.io" step (job [68043615120](https://github.com/linksplatform/Numbers/actions/runs/23390203130/job/68043615120))
- All other jobs (lint, test, coverage, build) passed successfully

## Root Cause Analysis

### Primary Failure: Missing `CARGO_REGISTRY_TOKEN` Secret

The publish step used inline bash that hard-failed (`exit 1`) when no token was configured:

```bash
if [ -n "$CARGO_REGISTRY_TOKEN" ]; then
cargo publish --token "$CARGO_REGISTRY_TOKEN"
else
echo "::error::CARGO_REGISTRY_TOKEN or CARGO_TOKEN secret is not configured."
exit 1 # <-- Hard failure, no graceful handling
fi
```

The template's `publish-crate.rs` script handles this more gracefully:
- Warns when no token is set but still attempts publish
- Handles "already exists" responses without failing
- Provides clear diagnostic messages for auth failures
- Uses `--allow-dirty` flag for CI contexts

### Secondary Issues: Divergence from Template

Comparing the Numbers repo's CI/CD with the template revealed multiple gaps:

| Feature | Numbers (Before) | Template | Impact |
|---------|-----------------|----------|--------|
| Script language | Node.js (.mjs) | Rust (.rs) | Dependency on Node.js runtime + unpkg.com |
| Change detection | Path filters only | `detect-code-changes.rs` | No smart job skipping |
| Version check | None | `check-version-modification.rs` | Manual version edits could break pipeline |
| Release check | Git tags | Crates.io API | Tags don't mean package published |
| Publish script | Inline bash | `publish-crate.rs` | No graceful error handling |
| Clippy | Not run | `cargo clippy --all-targets` | Missing lint coverage |
| Doc tests | Not run | `cargo test --doc` | Missing doc test coverage |
| Package check | Not run | `cargo package --list` | No package verification |
| Changelog check | Directory-based | PR-diff-based | False positives from leftover fragments |
| Changelog PR mode | Not available | `changelog-pr` | No manual changelog PR workflow |
| Job conditions | Simple `if` | `always() && !cancelled()` | Jobs could be skipped incorrectly |

## Solution

### Changes Made

1. **Migrated all scripts from Node.js (.mjs) to Rust (.rs)**
- Eliminates runtime dependency on Node.js and external package fetching
- All scripts use `rust-script` for execution
- Scripts auto-detect single-language vs multi-language repo structure

2. **Added missing scripts from template**
- `publish-crate.rs` — Graceful crates.io publishing
- `check-release-needed.rs` — Checks crates.io instead of git tags
- `check-version-modification.rs` — Prevents manual version edits
- `detect-code-changes.rs` — Smart change detection
- `git-config.rs` — Git user configuration
- `get-version.rs` — Version extraction for CI outputs
- `rust-paths.rs` — Multi-language path detection
- `check-changelog-fragment.rs` — PR-diff-aware validation
- `create-changelog-fragment.rs` — Manual changelog fragment creation

3. **Updated workflow (`rust.yml`)**
- Added `detect-changes` job
- Added `version-check` job
- Added `changelog-pr` release mode
- Added Clippy, doc tests, and package verification
- Proper `always() && !cancelled()` job conditions
- Replaced inline bash with Rust scripts

## Lessons Learned

1. **Check crates.io, not git tags**: Git tags can exist without the package being published. The source of truth for Rust packages is crates.io.
2. **Graceful publish handling**: The publish step should handle "already exists" and auth failures gracefully instead of hard-failing.
3. **Smart change detection**: Running all CI jobs for every change is wasteful. Detect what changed and skip unnecessary jobs.
4. **PR-diff changelog checks**: Checking for fragments in the directory can give false positives when leftover fragments from previous releases exist. Check the PR diff instead.

## Data Files

- `ci-logs/ci-run-23390203130.log.gz` — Full CI/CD run log from the failing run
Binary file not shown.
176 changes: 176 additions & 0 deletions scripts/bump-version.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
#!/usr/bin/env rust-script
//! Bump version in Cargo.toml
//!
//! Usage: rust-script scripts/bump-version.rs --bump-type <major|minor|patch> [--dry-run] [--rust-root <path>]
//!
//! Supports both single-language and multi-language repository structures:
//! - Single-language: Cargo.toml in repository root
//! - Multi-language: Cargo.toml in rust/ subfolder
//!
//! ```cargo
//! [dependencies]
//! regex = "1"
//! ```

use std::env;
use std::fs;
use std::path::Path;
use std::process::exit;
use regex::Regex;

#[derive(Debug, Clone, Copy, PartialEq)]
enum BumpType {
Major,
Minor,
Patch,
}

impl BumpType {
fn from_str(s: &str) -> Option<BumpType> {
match s.to_lowercase().as_str() {
"major" => Some(BumpType::Major),
"minor" => Some(BumpType::Minor),
"patch" => Some(BumpType::Patch),
_ => None,
}
}
}

struct Version {
major: u32,
minor: u32,
patch: u32,
}

impl Version {
fn bump(&self, bump_type: BumpType) -> String {
match bump_type {
BumpType::Major => format!("{}.0.0", self.major + 1),
BumpType::Minor => format!("{}.{}.0", self.major, self.minor + 1),
BumpType::Patch => format!("{}.{}.{}", self.major, self.minor, self.patch + 1),
}
}

fn to_string(&self) -> String {
format!("{}.{}.{}", self.major, self.minor, self.patch)
}
}

fn get_arg(name: &str) -> Option<String> {
let args: Vec<String> = env::args().collect();
let flag = format!("--{}", name);

if let Some(idx) = args.iter().position(|a| a == &flag) {
return args.get(idx + 1).cloned();
}

let env_name = name.to_uppercase().replace('-', "_");
env::var(&env_name).ok().filter(|s| !s.is_empty())
}

fn has_flag(name: &str) -> bool {
let args: Vec<String> = env::args().collect();
let flag = format!("--{}", name);
args.contains(&flag)
}

fn get_rust_root() -> String {
if let Some(root) = get_arg("rust-root") {
return root;
}

if Path::new("./Cargo.toml").exists() {
eprintln!("Detected single-language repository (Cargo.toml in root)");
return ".".to_string();
}

if Path::new("./rust/Cargo.toml").exists() {
eprintln!("Detected multi-language repository (Cargo.toml in rust/)");
return "rust".to_string();
}

eprintln!("Error: Could not find Cargo.toml in expected locations");
exit(1);
}

fn get_cargo_toml_path(rust_root: &str) -> String {
if rust_root == "." {
"./Cargo.toml".to_string()
} else {
format!("{}/Cargo.toml", rust_root)
}
}

fn get_current_version(cargo_toml_path: &str) -> Result<Version, String> {
let content = fs::read_to_string(cargo_toml_path)
.map_err(|e| format!("Failed to read {}: {}", cargo_toml_path, e))?;

let re = Regex::new(r#"(?m)^version\s*=\s*"(\d+)\.(\d+)\.(\d+)""#).unwrap();

if let Some(caps) = re.captures(&content) {
let major: u32 = caps.get(1).unwrap().as_str().parse().unwrap();
let minor: u32 = caps.get(2).unwrap().as_str().parse().unwrap();
let patch: u32 = caps.get(3).unwrap().as_str().parse().unwrap();
Ok(Version { major, minor, patch })
} else {
Err(format!("Could not parse version from {}", cargo_toml_path))
}
}

fn update_cargo_toml(cargo_toml_path: &str, new_version: &str) -> Result<(), String> {
let content = fs::read_to_string(cargo_toml_path)
.map_err(|e| format!("Failed to read {}: {}", cargo_toml_path, e))?;

let re = Regex::new(r#"(?m)^(version\s*=\s*")[^"]+(")"#).unwrap();
let new_content = re.replace(&content, format!("${{1}}{}${{2}}", new_version).as_str());

fs::write(cargo_toml_path, new_content.as_ref())
.map_err(|e| format!("Failed to write {}: {}", cargo_toml_path, e))?;

Ok(())
}

fn main() {
let bump_type_str = match get_arg("bump-type") {
Some(s) => s,
None => {
eprintln!("Usage: rust-script scripts/bump-version.rs --bump-type <major|minor|patch> [--dry-run] [--rust-root <path>]");
exit(1);
}
};

let bump_type = match BumpType::from_str(&bump_type_str) {
Some(bt) => bt,
None => {
eprintln!("Invalid bump type: {}. Must be major, minor, or patch.", bump_type_str);
exit(1);
}
};

let dry_run = has_flag("dry-run");
let rust_root = get_rust_root();
let cargo_toml = get_cargo_toml_path(&rust_root);

let current = match get_current_version(&cargo_toml) {
Ok(v) => v,
Err(e) => {
eprintln!("Error: {}", e);
exit(1);
}
};

let new_version = current.bump(bump_type);

println!("Current version: {}", current.to_string());
println!("New version: {}", new_version);

if dry_run {
println!("Dry run - no changes made");
} else {
if let Err(e) = update_cargo_toml(&cargo_toml, &new_version) {
eprintln!("Error: {}", e);
exit(1);
}
println!("Updated {}", cargo_toml);
}
}
Loading
Loading