From b272f73e01acd3299868de68efbaa61a288c9f15 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Sun, 24 Aug 2025 22:24:22 +0000 Subject: [PATCH] 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. --- docs/cli/publish.md | 10 +++ src/cli/publish_command.zig | 39 ++++++++- .../PackageManager/CommandLineArguments.zig | 8 ++ test/cli/install/bun-publish.test.ts | 82 +++++++++++++++++++ 4 files changed, 137 insertions(+), 2 deletions(-) diff --git a/docs/cli/publish.md b/docs/cli/publish.md index 6adf9c4a84..c2d9f75cf1 100644 --- a/docs/cli/publish.md +++ b/docs/cli/publish.md @@ -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`). diff --git a/src/cli/publish_command.zig b/src/cli/publish_command.zig index 015b66735a..5bf2696867 100644 --- a/src/cli/publish_command.zig +++ b/src/cli/publish_command.zig @@ -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( \\Tag: {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("warning: 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("warning: 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, diff --git a/src/install/PackageManager/CommandLineArguments.zig b/src/install/PackageManager/CommandLineArguments.zig index 40b5cce93e..2d1a0987d9 100644 --- a/src/install/PackageManager/CommandLineArguments.zig +++ b/src/install/PackageManager/CommandLineArguments.zig @@ -158,6 +158,7 @@ const publish_params: []const ParamType = &(shared_params ++ [_]ParamType{ clap.parseParam("--otp Provide a one-time password for authentication") catch unreachable, clap.parseParam("--auth-type Specify the type of one-time password authentication (default is 'web')") catch unreachable, clap.parseParam("--gzip-level 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 { \\ Publish a pre-existing package tarball with tag 'next'. \\ bun publish --tag next ./path/to/tarball.tgz \\ + \\ Publish without failing when republishing over an existing version. + \\ bun publish --tolerate-republish + \\ \\Full documentation is available at https://bun.com/docs/cli/publish. \\ ; @@ -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 diff --git a/test/cli/install/bun-publish.test.ts b/test/cli/install/bun-publish.test.ts index 42f8b83afe..7aead5041b 100644 --- a/test/cli/install/bun-publish.test.ts +++ b/test/cli/install/bun-publish.test.ts @@ -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:"); + }); +});