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 1c9fcf9f8d..8951d05654 100644 --- a/src/cli/publish_command.zig +++ b/src/cli/publish_command.zig @@ -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( \\Tag: {s} diff --git a/src/install/PackageManager/CommandLineArguments.zig b/src/install/PackageManager/CommandLineArguments.zig index ffd4de09a2..6ce9dbde57 100644 --- a/src/install/PackageManager/CommandLineArguments.zig +++ b/src/install/PackageManager/CommandLineArguments.zig @@ -160,6 +160,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{ @@ -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 { \\ 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. \\ ; @@ -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 diff --git a/src/install/PackageManager/PackageManagerOptions.zig b/src/install/PackageManager/PackageManagerOptions.zig index c81d26b783..9914752006 100644 --- a/src/install/PackageManager/PackageManagerOptions.zig +++ b/src/install/PackageManager/PackageManagerOptions.zig @@ -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; diff --git a/test/cli/install/bun-publish.test.ts b/test/cli/install/bun-publish.test.ts index 5f0b083f3b..51d1c817ef 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).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:"); + }); +});