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
16 changes: 16 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
129 changes: 129 additions & 0 deletions docs/case-studies/issue-31/README.md
Original file line number Diff line number Diff line change
@@ -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)
171 changes: 171 additions & 0 deletions rust/scripts/publish-crate.mjs
Original file line number Diff line number Diff line change
@@ -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 <token>] [--rust-root <path>]
*
* 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();
Loading