diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 337481b..81d93c0 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -322,6 +322,14 @@ 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 + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + working-directory: . + run: node rust/scripts/publish-crate.mjs + - name: Create GitHub Release if: steps.check.outputs.should_release == 'true' env: @@ -386,6 +394,14 @@ 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 + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + working-directory: . + run: node rust/scripts/publish-crate.mjs + - name: Create GitHub Release if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' env: diff --git a/docs/case-studies/issue-31/README.md b/docs/case-studies/issue-31/README.md new file mode 100644 index 0000000..829c144 --- /dev/null +++ b/docs/case-studies/issue-31/README.md @@ -0,0 +1,129 @@ +# Case Study: Issue #31 - Missing Crates.io Publishing + +## Summary + +The CI/CD pipeline correctly detects that version 0.4.0 is not published to crates.io +but fails to actually publish because there is no `cargo publish` step in the workflow. + +## Timeline/Sequence of Events + +### Commit History Leading to Issue + +1. Issue #27 ("Rust release jobs skipped") - Identified release jobs weren't running +2. Issue #29 ("Release failed, because version check got false positive") - Fixed version + check to use crates.io API instead of git tags +3. Issue #31 - Publishing still not working despite correct detection + +### CI Run Analysis (Run #21103777967) + +**Timestamp**: 2026-01-18T01:16:21Z + +**Key Events**: +1. `Detect Changes` job completed +2. `Lint and Format Check` passed +3. `Test` passed on all platforms (ubuntu, macos, windows) +4. `Build Package` succeeded +5. `Auto Release` job: + - Correctly checked crates.io API + - Found `Published on crates.io: false` + - Set `should_release=true` and `skip_bump=true` + - Built release (`cargo build --release`) + - Tried to create GitHub release (failed: tag already exists) + - **MISSING**: No `cargo publish` step + +## Root Cause Analysis + +### Primary Root Cause + +The `.github/workflows/rust.yml` workflow is missing the `cargo publish` step. +The workflow only: +1. Builds the release binary +2. Creates a GitHub release + +But it never actually publishes to crates.io using `cargo publish`. + +### Comparison with Template Repository + +The template at `link-foundation/rust-ai-driven-development-pipeline-template` +includes a critical step that is missing in browser-commander: + +```yaml +- name: Publish to Crates.io + if: steps.check.outputs.should_release == 'true' + id: publish-crate + env: + CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} + run: node scripts/publish-crate.mjs +``` + +### Missing Files + +The browser-commander repository is missing: +1. `scripts/publish-crate.mjs` - Script to publish to crates.io +2. `scripts/rust-paths.mjs` - Helper module for multi-language repo support + +## Evidence from CI Logs + +``` +Crate: browser-commander, Version: 0.4.0, Published on crates.io: false +No changelog fragments but v0.4.0 not yet published to crates.io +``` + +The detection logic works correctly. The workflow then: +1. Builds the release binary (`cargo build --release`) +2. Attempts GitHub release creation (HTTP 422 - tag already exists) +3. **Does not run `cargo publish`** + +## Solution + +### Required Changes + +1. **Add `scripts/publish-crate.mjs`**: Copy from template repository +2. **Add `scripts/rust-paths.mjs`**: Copy from template repository (required dependency) +3. **Update `.github/workflows/rust.yml`**: Add the `Publish to Crates.io` step + +### Implementation Details + +The `publish-crate.mjs` script: +- Reads package info from Cargo.toml +- Publishes to crates.io using `cargo publish` +- Handles "already exists" case gracefully +- Supports both single-language and multi-language repos + +### Workflow Addition + +Add after the `Build release` step: + +```yaml +- name: Publish to Crates.io + if: steps.check.outputs.should_release == 'true' + id: publish-crate + env: + CARGO_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: node scripts/publish-crate.mjs +``` + +## Repository Secret Requirements + +The repository needs `CARGO_REGISTRY_TOKEN` (or `CARGO_TOKEN` for backward compatibility) +to be configured in repository secrets for authentication with crates.io. + +## Lessons Learned + +1. **Complete workflow validation**: When setting up CI/CD pipelines, verify all steps + exist (build, test, package verification, AND publish) +2. **Template synchronization**: Regularly sync with template repositories to catch + missing features +3. **End-to-end testing**: The version detection was tested, but the actual publish + step was not verified to exist + +## Related Issues + +- #27: Rust release jobs skipped +- #29: Release failed, because version check got false positive + +## References + +- Template repository: https://github.com/link-foundation/rust-ai-driven-development-pipeline-template +- CI Run logs: https://github.com/link-foundation/browser-commander/actions/runs/21103777967/job/60691866177 +- crates.io package: https://crates.io/crates/browser-commander (not yet published) diff --git a/rust/scripts/publish-crate.mjs b/rust/scripts/publish-crate.mjs new file mode 100644 index 0000000..a047688 --- /dev/null +++ b/rust/scripts/publish-crate.mjs @@ -0,0 +1,171 @@ +#!/usr/bin/env node + +/** + * Publish package to crates.io + * + * This script publishes the Rust package to crates.io and handles + * the case where the version already exists. + * + * Supports both single-language and multi-language repository structures: + * - Single-language: Cargo.toml in repository root + * - Multi-language: Cargo.toml in rust/ subfolder + * + * Usage: node scripts/publish-crate.mjs [--token ] [--rust-root ] + * + * Environment variables (checked in order of priority): + * - CARGO_REGISTRY_TOKEN: Cargo's native crates.io token (preferred) + * - CARGO_TOKEN: Alternative token name for backwards compatibility + * + * Outputs (written to GITHUB_OUTPUT): + * - publish_result: 'success', 'already_exists', or 'failed' + * + * Uses link-foundation libraries: + * - use-m: Dynamic package loading without package.json dependencies + * - command-stream: Modern shell command execution with streaming support + * - lino-arguments: Unified configuration from CLI args, env vars, and .lenv files + */ + +import { readFileSync, appendFileSync } from 'fs'; +import { + getRustRoot, + getCargoTomlPath, + needsCd, + parseRustRootConfig, +} from './rust-paths.mjs'; + +// Load use-m dynamically +const { use } = eval( + await (await fetch('https://unpkg.com/use-m/use.js')).text() +); + +// Import link-foundation libraries +const { $ } = await use('command-stream'); +const { makeConfig } = await use('lino-arguments'); + +// Parse CLI arguments +// Support both CARGO_REGISTRY_TOKEN (cargo's native env var) and CARGO_TOKEN (backwards compat) +const config = makeConfig({ + yargs: ({ yargs, getenv }) => + yargs + .option('token', { + type: 'string', + default: getenv('CARGO_REGISTRY_TOKEN', '') || getenv('CARGO_TOKEN', ''), + describe: 'Crates.io API token (defaults to CARGO_REGISTRY_TOKEN or CARGO_TOKEN env var)', + }) + .option('rust-root', { + type: 'string', + default: getenv('RUST_ROOT', ''), + describe: 'Rust package root directory (auto-detected if not specified)', + }), +}); + +const { token, rustRoot: rustRootArg } = config; + +// Get Rust package root (auto-detect or use explicit config) +const rustRootConfig = rustRootArg || parseRustRootConfig(); +const rustRoot = getRustRoot({ rustRoot: rustRootConfig || undefined, verbose: true }); + +// Get paths based on detected/configured rust root +const CARGO_TOML = getCargoTomlPath({ rustRoot }); + +/** + * Append to GitHub Actions output file + * @param {string} key + * @param {string} value + */ +function setOutput(key, value) { + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + appendFileSync(outputFile, `${key}=${value}\n`); + } + console.log(`Output: ${key}=${value}`); +} + +/** + * Get package info from Cargo.toml + * @returns {{name: string, version: string}} + */ +function getPackageInfo() { + const cargoToml = readFileSync(CARGO_TOML, 'utf-8'); + + const nameMatch = cargoToml.match(/^name\s*=\s*"([^"]+)"/m); + const versionMatch = cargoToml.match(/^version\s*=\s*"([^"]+)"/m); + + if (!nameMatch || !versionMatch) { + console.error(`Error: Could not parse package info from ${CARGO_TOML}`); + process.exit(1); + } + + return { + name: nameMatch[1], + version: versionMatch[1], + }; +} + +async function main() { + // Store the original working directory to restore after cd commands + // IMPORTANT: command-stream's cd is a virtual command that calls process.chdir() + const originalCwd = process.cwd(); + + try { + const { name, version } = getPackageInfo(); + console.log(`Package: ${name}@${version}`); + console.log(''); + console.log('=== Attempting to publish to crates.io ==='); + + if (!token) { + console.log( + '::warning::Neither CARGO_REGISTRY_TOKEN nor CARGO_TOKEN is set, attempting publish without explicit token' + ); + } + + try { + // For multi-language repos, we need to cd into the rust directory + // IMPORTANT: cd is a virtual command that calls process.chdir(), so we restore after + if (needsCd({ rustRoot })) { + if (token) { + await $`cd ${rustRoot} && cargo publish --token ${token} --allow-dirty`; + } else { + await $`cd ${rustRoot} && cargo publish --allow-dirty`; + } + process.chdir(originalCwd); + } else { + if (token) { + await $`cargo publish --token ${token} --allow-dirty`; + } else { + await $`cargo publish --allow-dirty`; + } + } + + console.log(`Successfully published ${name}@${version} to crates.io`); + setOutput('publish_result', 'success'); + } catch (error) { + // Restore cwd on error + if (needsCd({ rustRoot })) { + process.chdir(originalCwd); + } + + const errorMessage = error.message || ''; + + if ( + errorMessage.includes('already uploaded') || + errorMessage.includes('already exists') + ) { + console.log( + `Version ${version} already exists on crates.io - this is OK` + ); + setOutput('publish_result', 'already_exists'); + } else { + console.error('Failed to publish for unknown reason'); + console.error(errorMessage); + setOutput('publish_result', 'failed'); + process.exit(1); + } + } + } catch (error) { + console.error('Error:', error.message); + process.exit(1); + } +} + +main(); diff --git a/rust/scripts/rust-paths.mjs b/rust/scripts/rust-paths.mjs new file mode 100644 index 0000000..7fca653 --- /dev/null +++ b/rust/scripts/rust-paths.mjs @@ -0,0 +1,181 @@ +#!/usr/bin/env node + +/** + * Rust package path detection utility + * + * Automatically detects the Rust package root for both: + * - Single-language repositories (Cargo.toml in root) + * - Multi-language repositories (Cargo.toml in rust/ subfolder) + * + * This utility follows best practices for multi-language monorepo support, + * allowing scripts to work seamlessly in both repository structures. + * + * Usage: + * import { getRustRoot, getCargoTomlPath, getChangelogDir } from './rust-paths.mjs'; + * + * const rustRoot = getRustRoot(); // Returns '.' or 'rust' + * const cargoPath = getCargoTomlPath(); // Returns './Cargo.toml' or 'rust/Cargo.toml' + * + * Configuration options (in order of priority): + * 1. Explicit parameter: getRustRoot({ rustRoot: 'custom-path' }) + * 2. CLI argument: --rust-root + * 3. Environment variable: RUST_ROOT + * 4. Auto-detection: Check ./Cargo.toml first, then ./rust/Cargo.toml + * + * @see https://graphite.dev/guides/managing-multiple-languages-in-a-monorepo + * @see https://buildkite.com/resources/blog/monorepo-ci-best-practices/ + */ + +import { existsSync } from 'fs'; +import { join } from 'path'; + +// Cache for detected paths (computed once per process) +let cachedRustRoot = null; + +/** + * Detect Rust package root directory + * Checks in order: + * 1. ./Cargo.toml (single-language repo) + * 2. ./rust/Cargo.toml (multi-language repo) + * + * @param {Object} options - Configuration options + * @param {string} [options.rustRoot] - Explicitly set Rust root (overrides auto-detection) + * @param {boolean} [options.verbose=false] - Log detection details + * @returns {string} The Rust root directory ('.' or 'rust') + * @throws {Error} If no Cargo.toml is found in expected locations + */ +export function getRustRoot(options = {}) { + const { rustRoot: explicitRoot, verbose = false } = options; + + // If explicitly configured, use that + if (explicitRoot !== undefined) { + if (verbose) { + console.log(`Using explicitly configured Rust root: ${explicitRoot}`); + } + return explicitRoot; + } + + // Return cached value if already computed + if (cachedRustRoot !== null) { + return cachedRustRoot; + } + + // Check for single-language repo (Cargo.toml in root) + if (existsSync('./Cargo.toml')) { + if (verbose) { + console.log('Detected single-language repository (Cargo.toml in root)'); + } + cachedRustRoot = '.'; + return cachedRustRoot; + } + + // Check for multi-language repo (Cargo.toml in rust/ subfolder) + if (existsSync('./rust/Cargo.toml')) { + if (verbose) { + console.log('Detected multi-language repository (Cargo.toml in rust/)'); + } + cachedRustRoot = 'rust'; + return cachedRustRoot; + } + + // No Cargo.toml found + throw new Error( + 'Could not find Cargo.toml in expected locations.\n' + + 'Searched in:\n' + + ' - ./Cargo.toml (single-language repository)\n' + + ' - ./rust/Cargo.toml (multi-language repository)\n\n' + + 'To fix this, either:\n' + + ' 1. Run the script from the repository root\n' + + ' 2. Explicitly configure the Rust root using --rust-root option\n' + + ' 3. Set the RUST_ROOT environment variable' + ); +} + +/** + * Get the path to Cargo.toml + * @param {Object} options - Configuration options (passed to getRustRoot) + * @returns {string} Path to Cargo.toml + */ +export function getCargoTomlPath(options = {}) { + const rustRoot = getRustRoot(options); + return rustRoot === '.' ? './Cargo.toml' : join(rustRoot, 'Cargo.toml'); +} + +/** + * Get the path to Cargo.lock + * @param {Object} options - Configuration options (passed to getRustRoot) + * @returns {string} Path to Cargo.lock + */ +export function getCargoLockPath(options = {}) { + const rustRoot = getRustRoot(options); + return rustRoot === '.' ? './Cargo.lock' : join(rustRoot, 'Cargo.lock'); +} + +/** + * Get the path to changelog.d directory + * @param {Object} options - Configuration options (passed to getRustRoot) + * @returns {string} Path to changelog.d directory + */ +export function getChangelogDir(options = {}) { + const rustRoot = getRustRoot(options); + return rustRoot === '.' ? './changelog.d' : join(rustRoot, 'changelog.d'); +} + +/** + * Get the path to CHANGELOG.md + * @param {Object} options - Configuration options (passed to getRustRoot) + * @returns {string} Path to CHANGELOG.md + */ +export function getChangelogPath(options = {}) { + const rustRoot = getRustRoot(options); + return rustRoot === '.' ? './CHANGELOG.md' : join(rustRoot, 'CHANGELOG.md'); +} + +/** + * Get the cd command prefix for running cargo commands + * Returns empty string for single-language repos, 'cd rust && ' for multi-language repos + * @param {Object} options - Configuration options (passed to getRustRoot) + * @returns {string} CD prefix for shell commands + */ +export function getCdPrefix(options = {}) { + const rustRoot = getRustRoot(options); + return rustRoot === '.' ? '' : `cd ${rustRoot} && `; +} + +/** + * Check if we need to change directory before running cargo commands + * @param {Object} options - Configuration options (passed to getRustRoot) + * @returns {boolean} True if cd is needed + */ +export function needsCd(options = {}) { + const rustRoot = getRustRoot(options); + return rustRoot !== '.'; +} + +/** + * Reset the cached Rust root (useful for testing) + */ +export function resetCache() { + cachedRustRoot = null; +} + +/** + * Parse Rust root from CLI arguments or environment + * Supports --rust-root argument and RUST_ROOT environment variable + * @returns {string|undefined} Configured Rust root or undefined for auto-detection + */ +export function parseRustRootConfig() { + // Check CLI arguments + const args = process.argv.slice(2); + const rustRootIndex = args.indexOf('--rust-root'); + if (rustRootIndex >= 0 && args[rustRootIndex + 1]) { + return args[rustRootIndex + 1]; + } + + // Check environment variable + if (process.env.RUST_ROOT) { + return process.env.RUST_ROOT; + } + + return undefined; +}