Fix --tolerate-republish flag in bun publish (continues PR #22107) (#22381)

## Summary
This PR continues the work from #22107 to fix the `--tolerate-republish`
flag implementation in `bun publish`.

### Changes:
- **Pre-check version existence**: Before attempting to publish with
`--tolerate-republish`, check if the version already exists on the
registry
- **Improved version checking**: Use GET request to package endpoint
instead of HEAD, then parse JSON response to check if specific version
exists
- **Correct output stream**: Output warning to stderr instead of stdout
for consistency with test expectations
- **Better error handling**: Update test to accept both 403 and 409 HTTP
error codes for duplicate publish attempts

### Test fixes:
The tests were failing because:
1. The mock registry returns 409 Conflict (not 403) for duplicate
packages
2. The warning message wasn't appearing in stderr as expected
3. The version check was using HEAD request which doesn't reliably
return version info

## Test plan
- [x] Fixed failing tests for `--tolerate-republish` functionality
- [x] Tests now properly handle both 403 and 409 error responses
- [x] Warning messages appear correctly in stderr

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
This commit is contained in:
robobun
2025-09-30 13:25:50 -07:00
committed by GitHub
parent a89e61fcaa
commit c5005a37d7
5 changed files with 195 additions and 0 deletions

View File

@@ -82,6 +82,16 @@ The `--dry-run` flag can be used to simulate the publish process without actuall
$ bun publish --dry-run
```
### `--tolerate-republish`
The `--tolerate-republish` flag makes `bun publish` exit with code 0 instead of code 1 when attempting to republish over an existing version number. This is useful in automated workflows where republishing the same version might occur and should not be treated as an error.
```sh
$ bun publish --tolerate-republish
```
Without this flag, attempting to publish a version that already exists will result in an error and exit code 1. With this flag, the command will exit successfully even when trying to republish an existing version.
### `--gzip-level`
Specify the level of gzip compression to use when packing the package. Only applies to `bun publish` without a tarball path argument. Values range from `0` to `9` (default is `9`).

View File

@@ -451,6 +451,83 @@ pub const PublishCommand = struct {
NeedAuth,
};
fn checkPackageVersionExists(
allocator: std.mem.Allocator,
package_name: string,
version: string,
registry: *const Npm.Registry.Scope,
) bool {
var url_buf = std.ArrayList(u8).init(allocator);
defer url_buf.deinit();
const registry_url = strings.withoutTrailingSlash(registry.url.href);
const encoded_name = bun.fmt.dependencyUrl(package_name);
// Try to get package metadata to check if version exists
url_buf.writer().print("{s}/{s}", .{ registry_url, encoded_name }) catch return false;
const package_url = URL.parse(url_buf.items);
var response_buf = MutableString.init(allocator, 1024) catch return false;
defer response_buf.deinit();
var headers = http.HeaderBuilder{};
headers.count("accept", "application/json");
var auth_buf = std.ArrayList(u8).init(allocator);
defer auth_buf.deinit();
if (registry.token.len > 0) {
auth_buf.writer().print("Bearer {s}", .{registry.token}) catch return false;
headers.count("authorization", auth_buf.items);
} else if (registry.auth.len > 0) {
auth_buf.writer().print("Basic {s}", .{registry.auth}) catch return false;
headers.count("authorization", auth_buf.items);
}
headers.allocate(allocator) catch return false;
headers.append("accept", "application/json");
if (registry.token.len > 0) {
auth_buf.clearRetainingCapacity();
auth_buf.writer().print("Bearer {s}", .{registry.token}) catch return false;
headers.append("authorization", auth_buf.items);
} else if (registry.auth.len > 0) {
auth_buf.clearRetainingCapacity();
auth_buf.writer().print("Basic {s}", .{registry.auth}) catch return false;
headers.append("authorization", auth_buf.items);
}
var req = http.AsyncHTTP.initSync(
allocator,
.GET,
package_url,
headers.entries,
headers.content.ptr.?[0..headers.content.len],
&response_buf,
"",
null,
null,
.follow,
);
const res = req.sendSync() catch return false;
if (res.status_code != 200) return false;
// Parse the response to check if this specific version exists
const source = logger.Source.initPathString("???", response_buf.list.items);
var log = logger.Log.init(allocator);
const json = JSON.parseUTF8(&source, &log, allocator) catch return false;
// Check if the version exists in the versions object
if (json.get("versions")) |versions| {
if (versions.get(version)) |_| {
return true;
}
}
return false;
}
pub fn publish(
comptime directory_publish: bool,
ctx: *const Context(directory_publish),
@@ -461,6 +538,22 @@ pub const PublishCommand = struct {
return error.NeedAuth;
}
const tolerate_republish = ctx.manager.options.publish_config.tolerate_republish;
if (tolerate_republish) {
const version_without_build_tag = Dependency.withoutBuildTag(ctx.package_version);
const package_exists = checkPackageVersionExists(
ctx.allocator,
ctx.package_name,
version_without_build_tag,
registry,
);
if (package_exists) {
Output.warn("Registry already knows about version {s}; skipping.", .{version_without_build_tag});
return;
}
}
// continues from `printSummary`
Output.pretty(
\\<b><blue>Tag<r>: {s}

View File

@@ -160,6 +160,7 @@ const publish_params: []const ParamType = &(shared_params ++ [_]ParamType{
clap.parseParam("--otp <STR> Provide a one-time password for authentication") catch unreachable,
clap.parseParam("--auth-type <STR> Specify the type of one-time password authentication (default is 'web')") catch unreachable,
clap.parseParam("--gzip-level <STR> Specify a custom compression level for gzip. Default is 9.") catch unreachable,
clap.parseParam("--tolerate-republish Don't exit with code 1 when republishing over an existing version number") catch unreachable,
});
const why_params: []const ParamType = &(shared_params ++ [_]ParamType{
@@ -220,6 +221,8 @@ registry: string = "",
publish_config: Options.PublishConfig = .{},
tolerate_republish: bool = false,
ca: []const string = &.{},
ca_file_name: string = "",
@@ -611,6 +614,9 @@ pub fn printHelp(subcommand: Subcommand) void {
\\ <d>Publish a pre-existing package tarball with tag 'next'.<r>
\\ <b><green>bun publish<r> <cyan>--tag next<r> <blue>./path/to/tarball.tgz<r>
\\
\\ <d>Publish without failing when republishing over an existing version.<r>
\\ <b><green>bun publish<r> <cyan>--tolerate-republish<r>
\\
\\Full documentation is available at <magenta>https://bun.com/docs/cli/publish<r>.
\\
;
@@ -896,6 +902,8 @@ pub fn parse(allocator: std.mem.Allocator, comptime subcommand: Subcommand) !Com
Global.crash();
};
}
cli.tolerate_republish = args.flag("--tolerate-republish");
}
// link and unlink default to not saving, all others default to

View File

@@ -85,6 +85,7 @@ pub const PublishConfig = struct {
tag: string = "",
otp: string = "",
auth_type: ?AuthType = null,
tolerate_republish: bool = false,
};
pub const Access = enum {
@@ -646,6 +647,7 @@ pub fn load(
if (cli.publish_config.auth_type) |auth_type| {
this.publish_config.auth_type = auth_type;
}
this.publish_config.tolerate_republish = cli.tolerate_republish;
if (cli.ca.len > 0) {
this.ca = cli.ca;

View File

@@ -848,3 +848,85 @@ it("$npm_lifecycle_event is accurate during publish", async () => {
]);
expect(exitCode).toBe(0);
});
describe("--tolerate-republish", async () => {
test("republishing normally fails", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
const bunfig = await registry.authBunfig("republish-fail");
const pkgJson = {
name: "republish-test-1",
version: "1.0.0",
};
await Promise.all([
rm(join(registry.packagesPath, "republish-test-1"), { recursive: true, force: true }),
write(join(packageDir, "bunfig.toml"), bunfig),
write(packageJson, JSON.stringify(pkgJson)),
]);
// First publish should succeed
let { out, err, exitCode } = await publish(env, packageDir);
expect(exitCode).toBe(0);
expect(out).toContain("+ republish-test-1@1.0.0");
// Second publish should fail
({ out, err, exitCode } = await publish(env, packageDir));
expect(exitCode).toBe(1);
expect(err).toMatch(/403|409|already exists|already present|cannot publish/);
});
test("republishing with --tolerate-republish skips when version exists", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
const bunfig = await registry.authBunfig("republish-tolerate");
const pkgJson = {
name: "republish-test-2",
version: "1.0.0",
};
await Promise.all([
rm(join(registry.packagesPath, "republish-test-2"), { recursive: true, force: true }),
write(join(packageDir, "bunfig.toml"), bunfig),
write(packageJson, JSON.stringify(pkgJson)),
]);
// First publish should succeed
let { out, err, exitCode } = await publish(env, packageDir);
expect(exitCode).toBe(0);
expect(out).toContain("+ republish-test-2@1.0.0");
// Second publish with --tolerate-republish should skip
({ out, err, exitCode } = await publish(env, packageDir, "--tolerate-republish"));
expect(exitCode).toBe(0);
expect(err).toBe("warn: Registry already knows about version 1.0.0; skipping.\n");
expect(err).not.toContain("error:");
});
test("republishing tarball with --tolerate-republish skips when version exists", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
const bunfig = await registry.authBunfig("republish-tarball");
const pkgJson = {
name: "republish-test-3",
version: "1.0.0",
};
await Promise.all([
rm(join(registry.packagesPath, "republish-test-3"), { recursive: true, force: true }),
write(join(packageDir, "bunfig.toml"), bunfig),
write(packageJson, JSON.stringify(pkgJson)),
]);
// Create tarball
await pack(packageDir, env);
// First publish should succeed
let { out, err, exitCode } = await publish(env, packageDir, "./republish-test-3-1.0.0.tgz");
expect(exitCode).toBe(0);
expect(out).toContain("+ republish-test-3@1.0.0");
// Second publish with --tolerate-republish should skip
({ out, err, exitCode } = await publish(env, packageDir, "./republish-test-3-1.0.0.tgz", "--tolerate-republish"));
expect(exitCode).toBe(0);
expect(err).toBe("warn: Registry already knows about version 1.0.0; skipping.\n");
expect(err).not.toContain("error:");
});
});