Add --tolerate-republish flag to bun publish command

This flag allows `bun publish` to 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.

Implementation follows Yarn's design philosophy: only perform the
additional registry check when the flag is explicitly provided,
keeping the default fast path unchanged.

Changes:
- Add --tolerate-republish CLI flag parsing
- Implement republish error detection and tolerance logic
- Add help text and usage examples
- Add comprehensive test coverage
- Update CLI documentation

Note: Current implementation uses reactive error detection. Future
enhancement should implement Yarn's proactive registry check approach
for better efficiency when the flag is enabled.
This commit is contained in:
Claude Bot
2025-08-24 22:24:22 +00:00
parent fe3cbce1f0
commit b272f73e01
4 changed files with 137 additions and 2 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

@@ -334,7 +334,7 @@ pub const PublishCommand = struct {
Global.crash();
};
publish(false, &context) catch |err| {
publish(false, &context, cli.tolerate_republish) catch |err| {
switch (err) {
error.OutOfMemory => bun.outOfMemory(),
error.NeedAuth => {
@@ -381,7 +381,7 @@ pub const PublishCommand = struct {
// TODO: read this into memory
_ = bun.sys.unlink(context.abs_tarball_path);
publish(true, &context) catch |err| {
publish(true, &context, cli.tolerate_republish) catch |err| {
switch (err) {
error.OutOfMemory => bun.outOfMemory(),
error.NeedAuth => {
@@ -451,9 +451,27 @@ pub const PublishCommand = struct {
NeedAuth,
};
fn isRepublishError(status_code: u32, response_body: []const u8) bool {
// Only check for republish errors on 403/409 status codes
if (status_code != 403 and status_code != 409) {
return false;
}
// Check for common republish error messages
return strings.containsComptime(response_body, "cannot publish over") or
strings.containsComptime(response_body, "already exists") or
strings.containsComptime(response_body, "already published") or
strings.containsComptime(response_body, "previously published") or
strings.containsComptime(response_body, "version already exists") or
strings.containsComptime(response_body, "Cannot publish over") or
strings.containsComptime(response_body, "Already exists") or
strings.containsComptime(response_body, "You cannot publish");
}
pub fn publish(
comptime directory_publish: bool,
ctx: *const Context(directory_publish),
tolerate_republish: bool,
) PublishError!void {
const registry = ctx.manager.scopeForPackageName(ctx.package_name);
@@ -461,6 +479,11 @@ pub const PublishCommand = struct {
return error.NeedAuth;
}
// TODO: Implement Yarn's proactive approach here
// When --tolerate-republish is enabled, we should check if package version already exists
// BEFORE doing any expensive work (packing, uploading, etc.) by making a GET request
// to the registry API. For now, we use the reactive approach below.
// continues from `printSummary`
Output.pretty(
\\<b><blue>Tag<r>: {s}
@@ -552,6 +575,12 @@ pub const PublishCommand = struct {
};
if (!prompt_for_otp) {
// Check if this is a republish error and we should tolerate it
if (tolerate_republish and isRepublishError(res.status_code, response_buf.list.items)) {
Output.prettyln("<yellow>warning<r>: Registry already knows about version {s}; skipping.", .{Dependency.withoutBuildTag(ctx.package_version)});
return; // Skip publishing entirely
}
// general error
const otp_response = false;
try Npm.responseError(
@@ -611,6 +640,12 @@ pub const PublishCommand = struct {
switch (otp_res.status_code) {
400...std.math.maxInt(@TypeOf(otp_res.status_code)) => {
// Check if this is a republish error and we should tolerate it
if (tolerate_republish and isRepublishError(otp_res.status_code, response_buf.list.items)) {
Output.prettyln("<yellow>warning<r>: Registry already knows about version {s}; skipping.", .{Dependency.withoutBuildTag(ctx.package_version)});
return; // Skip publishing entirely
}
const otp_response = true;
try Npm.responseError(
ctx.allocator,

View File

@@ -158,6 +158,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{
@@ -218,6 +219,8 @@ registry: string = "",
publish_config: Options.PublishConfig = .{},
tolerate_republish: bool = false,
ca: []const string = &.{},
ca_file_name: string = "",
@@ -605,6 +608,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>.
\\
;
@@ -860,6 +866,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

@@ -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).toContain("403") || expect(err).toContain("already exists") || expect(err).toContain("cannot publish");
});
test("republishing with --tolerate-republish skips and succeeds", 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 and succeed
({ out, err, exitCode } = await publish(env, packageDir, "--tolerate-republish"));
expect(exitCode).toBe(0);
expect(err).toContain("warning: Registry already knows about version 1.0.0; skipping.");
expect(err).not.toContain("error:");
});
test("republishing tarball with --tolerate-republish skips and succeeds", 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 and succeed
({ out, err, exitCode } = await publish(env, packageDir, "./republish-test-3-1.0.0.tgz", "--tolerate-republish"));
expect(exitCode).toBe(0);
expect(err).toContain("warning: Registry already knows about version 1.0.0; skipping.");
expect(err).not.toContain("error:");
});
});