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:"); + }); +});