Skip to content

Commit f316a75

Browse files
konardclaude
andcommitted
fix(rust): Add cargo publish step to release workflow
The release workflow was correctly detecting that the package was not published to crates.io, but was missing the actual `cargo publish` step. Changes: - Add publish-crate.mjs script to handle crates.io publishing - Add rust-paths.mjs helper for multi-language repository support - Update rust.yml workflow to include publish step in both auto-release and manual-release jobs - Add case study documentation for issue #31 The workflow now: 1. Builds the release binary 2. Publishes to crates.io (NEW) 3. Creates GitHub release Fixes #31 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 3bbfd16 commit f316a75

File tree

4 files changed

+497
-0
lines changed

4 files changed

+497
-0
lines changed

.github/workflows/rust.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,14 @@ jobs:
322322
if: steps.check.outputs.should_release == 'true'
323323
run: cargo build --release
324324

325+
- name: Publish to Crates.io
326+
if: steps.check.outputs.should_release == 'true'
327+
id: publish-crate
328+
env:
329+
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
330+
working-directory: .
331+
run: node rust/scripts/publish-crate.mjs
332+
325333
- name: Create GitHub Release
326334
if: steps.check.outputs.should_release == 'true'
327335
env:
@@ -386,6 +394,14 @@ jobs:
386394
if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true'
387395
run: cargo build --release
388396

397+
- name: Publish to Crates.io
398+
if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true'
399+
id: publish-crate
400+
env:
401+
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
402+
working-directory: .
403+
run: node rust/scripts/publish-crate.mjs
404+
389405
- name: Create GitHub Release
390406
if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true'
391407
env:
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# Case Study: Issue #31 - Missing Crates.io Publishing
2+
3+
## Summary
4+
5+
The CI/CD pipeline correctly detects that version 0.4.0 is not published to crates.io
6+
but fails to actually publish because there is no `cargo publish` step in the workflow.
7+
8+
## Timeline/Sequence of Events
9+
10+
### Commit History Leading to Issue
11+
12+
1. Issue #27 ("Rust release jobs skipped") - Identified release jobs weren't running
13+
2. Issue #29 ("Release failed, because version check got false positive") - Fixed version
14+
check to use crates.io API instead of git tags
15+
3. Issue #31 - Publishing still not working despite correct detection
16+
17+
### CI Run Analysis (Run #21103777967)
18+
19+
**Timestamp**: 2026-01-18T01:16:21Z
20+
21+
**Key Events**:
22+
1. `Detect Changes` job completed
23+
2. `Lint and Format Check` passed
24+
3. `Test` passed on all platforms (ubuntu, macos, windows)
25+
4. `Build Package` succeeded
26+
5. `Auto Release` job:
27+
- Correctly checked crates.io API
28+
- Found `Published on crates.io: false`
29+
- Set `should_release=true` and `skip_bump=true`
30+
- Built release (`cargo build --release`)
31+
- Tried to create GitHub release (failed: tag already exists)
32+
- **MISSING**: No `cargo publish` step
33+
34+
## Root Cause Analysis
35+
36+
### Primary Root Cause
37+
38+
The `.github/workflows/rust.yml` workflow is missing the `cargo publish` step.
39+
The workflow only:
40+
1. Builds the release binary
41+
2. Creates a GitHub release
42+
43+
But it never actually publishes to crates.io using `cargo publish`.
44+
45+
### Comparison with Template Repository
46+
47+
The template at `link-foundation/rust-ai-driven-development-pipeline-template`
48+
includes a critical step that is missing in browser-commander:
49+
50+
```yaml
51+
- name: Publish to Crates.io
52+
if: steps.check.outputs.should_release == 'true'
53+
id: publish-crate
54+
env:
55+
CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }}
56+
run: node scripts/publish-crate.mjs
57+
```
58+
59+
### Missing Files
60+
61+
The browser-commander repository is missing:
62+
1. `scripts/publish-crate.mjs` - Script to publish to crates.io
63+
2. `scripts/rust-paths.mjs` - Helper module for multi-language repo support
64+
65+
## Evidence from CI Logs
66+
67+
```
68+
Crate: browser-commander, Version: 0.4.0, Published on crates.io: false
69+
No changelog fragments but v0.4.0 not yet published to crates.io
70+
```
71+
72+
The detection logic works correctly. The workflow then:
73+
1. Builds the release binary (`cargo build --release`)
74+
2. Attempts GitHub release creation (HTTP 422 - tag already exists)
75+
3. **Does not run `cargo publish`**
76+
77+
## Solution
78+
79+
### Required Changes
80+
81+
1. **Add `scripts/publish-crate.mjs`**: Copy from template repository
82+
2. **Add `scripts/rust-paths.mjs`**: Copy from template repository (required dependency)
83+
3. **Update `.github/workflows/rust.yml`**: Add the `Publish to Crates.io` step
84+
85+
### Implementation Details
86+
87+
The `publish-crate.mjs` script:
88+
- Reads package info from Cargo.toml
89+
- Publishes to crates.io using `cargo publish`
90+
- Handles "already exists" case gracefully
91+
- Supports both single-language and multi-language repos
92+
93+
### Workflow Addition
94+
95+
Add after the `Build release` step:
96+
97+
```yaml
98+
- name: Publish to Crates.io
99+
if: steps.check.outputs.should_release == 'true'
100+
id: publish-crate
101+
env:
102+
CARGO_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
103+
run: node scripts/publish-crate.mjs
104+
```
105+
106+
## Repository Secret Requirements
107+
108+
The repository needs `CARGO_REGISTRY_TOKEN` (or `CARGO_TOKEN` for backward compatibility)
109+
to be configured in repository secrets for authentication with crates.io.
110+
111+
## Lessons Learned
112+
113+
1. **Complete workflow validation**: When setting up CI/CD pipelines, verify all steps
114+
exist (build, test, package verification, AND publish)
115+
2. **Template synchronization**: Regularly sync with template repositories to catch
116+
missing features
117+
3. **End-to-end testing**: The version detection was tested, but the actual publish
118+
step was not verified to exist
119+
120+
## Related Issues
121+
122+
- #27: Rust release jobs skipped
123+
- #29: Release failed, because version check got false positive
124+
125+
## References
126+
127+
- Template repository: https://github.com/link-foundation/rust-ai-driven-development-pipeline-template
128+
- CI Run logs: https://github.com/link-foundation/browser-commander/actions/runs/21103777967/job/60691866177
129+
- crates.io package: https://crates.io/crates/browser-commander (not yet published)

rust/scripts/publish-crate.mjs

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Publish package to crates.io
5+
*
6+
* This script publishes the Rust package to crates.io and handles
7+
* the case where the version already exists.
8+
*
9+
* Supports both single-language and multi-language repository structures:
10+
* - Single-language: Cargo.toml in repository root
11+
* - Multi-language: Cargo.toml in rust/ subfolder
12+
*
13+
* Usage: node scripts/publish-crate.mjs [--token <token>] [--rust-root <path>]
14+
*
15+
* Environment variables (checked in order of priority):
16+
* - CARGO_REGISTRY_TOKEN: Cargo's native crates.io token (preferred)
17+
* - CARGO_TOKEN: Alternative token name for backwards compatibility
18+
*
19+
* Outputs (written to GITHUB_OUTPUT):
20+
* - publish_result: 'success', 'already_exists', or 'failed'
21+
*
22+
* Uses link-foundation libraries:
23+
* - use-m: Dynamic package loading without package.json dependencies
24+
* - command-stream: Modern shell command execution with streaming support
25+
* - lino-arguments: Unified configuration from CLI args, env vars, and .lenv files
26+
*/
27+
28+
import { readFileSync, appendFileSync } from 'fs';
29+
import {
30+
getRustRoot,
31+
getCargoTomlPath,
32+
needsCd,
33+
parseRustRootConfig,
34+
} from './rust-paths.mjs';
35+
36+
// Load use-m dynamically
37+
const { use } = eval(
38+
await (await fetch('https://unpkg.com/use-m/use.js')).text()
39+
);
40+
41+
// Import link-foundation libraries
42+
const { $ } = await use('command-stream');
43+
const { makeConfig } = await use('lino-arguments');
44+
45+
// Parse CLI arguments
46+
// Support both CARGO_REGISTRY_TOKEN (cargo's native env var) and CARGO_TOKEN (backwards compat)
47+
const config = makeConfig({
48+
yargs: ({ yargs, getenv }) =>
49+
yargs
50+
.option('token', {
51+
type: 'string',
52+
default: getenv('CARGO_REGISTRY_TOKEN', '') || getenv('CARGO_TOKEN', ''),
53+
describe: 'Crates.io API token (defaults to CARGO_REGISTRY_TOKEN or CARGO_TOKEN env var)',
54+
})
55+
.option('rust-root', {
56+
type: 'string',
57+
default: getenv('RUST_ROOT', ''),
58+
describe: 'Rust package root directory (auto-detected if not specified)',
59+
}),
60+
});
61+
62+
const { token, rustRoot: rustRootArg } = config;
63+
64+
// Get Rust package root (auto-detect or use explicit config)
65+
const rustRootConfig = rustRootArg || parseRustRootConfig();
66+
const rustRoot = getRustRoot({ rustRoot: rustRootConfig || undefined, verbose: true });
67+
68+
// Get paths based on detected/configured rust root
69+
const CARGO_TOML = getCargoTomlPath({ rustRoot });
70+
71+
/**
72+
* Append to GitHub Actions output file
73+
* @param {string} key
74+
* @param {string} value
75+
*/
76+
function setOutput(key, value) {
77+
const outputFile = process.env.GITHUB_OUTPUT;
78+
if (outputFile) {
79+
appendFileSync(outputFile, `${key}=${value}\n`);
80+
}
81+
console.log(`Output: ${key}=${value}`);
82+
}
83+
84+
/**
85+
* Get package info from Cargo.toml
86+
* @returns {{name: string, version: string}}
87+
*/
88+
function getPackageInfo() {
89+
const cargoToml = readFileSync(CARGO_TOML, 'utf-8');
90+
91+
const nameMatch = cargoToml.match(/^name\s*=\s*"([^"]+)"/m);
92+
const versionMatch = cargoToml.match(/^version\s*=\s*"([^"]+)"/m);
93+
94+
if (!nameMatch || !versionMatch) {
95+
console.error(`Error: Could not parse package info from ${CARGO_TOML}`);
96+
process.exit(1);
97+
}
98+
99+
return {
100+
name: nameMatch[1],
101+
version: versionMatch[1],
102+
};
103+
}
104+
105+
async function main() {
106+
// Store the original working directory to restore after cd commands
107+
// IMPORTANT: command-stream's cd is a virtual command that calls process.chdir()
108+
const originalCwd = process.cwd();
109+
110+
try {
111+
const { name, version } = getPackageInfo();
112+
console.log(`Package: ${name}@${version}`);
113+
console.log('');
114+
console.log('=== Attempting to publish to crates.io ===');
115+
116+
if (!token) {
117+
console.log(
118+
'::warning::Neither CARGO_REGISTRY_TOKEN nor CARGO_TOKEN is set, attempting publish without explicit token'
119+
);
120+
}
121+
122+
try {
123+
// For multi-language repos, we need to cd into the rust directory
124+
// IMPORTANT: cd is a virtual command that calls process.chdir(), so we restore after
125+
if (needsCd({ rustRoot })) {
126+
if (token) {
127+
await $`cd ${rustRoot} && cargo publish --token ${token} --allow-dirty`;
128+
} else {
129+
await $`cd ${rustRoot} && cargo publish --allow-dirty`;
130+
}
131+
process.chdir(originalCwd);
132+
} else {
133+
if (token) {
134+
await $`cargo publish --token ${token} --allow-dirty`;
135+
} else {
136+
await $`cargo publish --allow-dirty`;
137+
}
138+
}
139+
140+
console.log(`Successfully published ${name}@${version} to crates.io`);
141+
setOutput('publish_result', 'success');
142+
} catch (error) {
143+
// Restore cwd on error
144+
if (needsCd({ rustRoot })) {
145+
process.chdir(originalCwd);
146+
}
147+
148+
const errorMessage = error.message || '';
149+
150+
if (
151+
errorMessage.includes('already uploaded') ||
152+
errorMessage.includes('already exists')
153+
) {
154+
console.log(
155+
`Version ${version} already exists on crates.io - this is OK`
156+
);
157+
setOutput('publish_result', 'already_exists');
158+
} else {
159+
console.error('Failed to publish for unknown reason');
160+
console.error(errorMessage);
161+
setOutput('publish_result', 'failed');
162+
process.exit(1);
163+
}
164+
}
165+
} catch (error) {
166+
console.error('Error:', error.message);
167+
process.exit(1);
168+
}
169+
}
170+
171+
main();

0 commit comments

Comments
 (0)