Skip to content

Rework publishing to work with all popular package managers#674

Open
Andarist wants to merge 6 commits intomainfrom
publish-refactor
Open

Rework publishing to work with all popular package managers#674
Andarist wants to merge 6 commits intomainfrom
publish-refactor

Conversation

@Andarist
Copy link
Member

@Andarist Andarist commented Nov 20, 2021

fixes #598
fixes #432
fixes #813
fixes #1454
(maybe #1411)

at the moment this is being opened as a draft, to gather initial feedback

The goal of this PR is to delegate as much as possible to yarn berry / pnpm so they can do their magic with rewriting package manifests and stuff

TODO:

cc @schickling @zkochan (might be interesting to you)

@Andarist Andarist requested a review from emmatown November 20, 2021 21:21
@changeset-bot
Copy link

changeset-bot bot commented Nov 20, 2021

🦋 Changeset detected

Latest commit: 7608211

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@changesets/cli Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link
Member

@emmatown emmatown left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should log stdout and stderr when publish fails so people can debug when something goes wrong like we fail to parse the json? (and maybe even land that and the getLastJsonObjectFromString stuff separately to package manager changes?)

[
...publishTool.args,
opts.cwd,
"--json",
Copy link

@belgattitude belgattitude Nov 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like if yarn 2+ does not support --json, the argument should be conditionally added

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, thank you for spotting that - this is an obvious oversight on my part, I knew that this ain't supported as it makes me handle yarn berry in a different way just a few lines later

just out of curiosity - is using an unknown flag makes yarn throw an error?

Copy link

@belgattitude belgattitude Nov 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately yes, just tested with your current P/R to be sure (yarn 3.2.0-rc.5):

soluble-io/cache-interop#222 (comment)

BTW I love the butterfly in the yarn output, made me smile :)

🦋  error Unknown Syntax Error: Extraneous positional argument ("/home/sebastien/github/cache-interop/packages/cache-redis").

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you have tested the other case 😅 but this one doesn't work either:

Unknown Syntax Error: Unsupported option name ("--json").

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep 😅, I've been struggling a bit with how to build your P/R to use it...

But that definitely happened and it was nice to see.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will make sure to setup snapshot releases for PRs before we go into re-testing this after I apply the suggested changes

["publish", opts.cwd, "--json", ...publishFlags],
[
...publishTool.args,
opts.cwd,
Copy link

@belgattitude belgattitude Nov 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

opts.cwd

I don't think it will work with yarn 2+, not sure about the other package managers (https://yarnpkg.com/cli/npm/publish has no reference to path)

I think this should work

 const args = [
    ...publishTool.args,
   // "--json",  // <- assuming here we add --json for naything !== yarn 2+
    ...publishFlags,
    ...publishTool.flags
  ];
  let { code, stdout, stderr } = await spawn(
    publishTool.name,
    args,
    {
      // Here we can send the current working directory
      cwd: opts.cwd,
      env: Object.assign({}, process.env, envOverride)
    }
  );

Just tested it and it works on yarn 3. But I'm wondering if it would be cleaner to extract the command and add tests on it. I'll have a look tomorrow to see if I can help

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, yes - good catch. I got Unknown Syntax Error: Extraneous positional argument ("packages/test"). with this.

I think it's safe to just use { cwd: opts.cwd } for all package managers.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just tested it and it works on yarn 3. But I'm wondering if it would be cleaner to extract the command and add tests on it. I'll have a look tomorrow to see if I can help

Tests for this whole thing would be great. I'm just unsure how to test this best - we really should have some e2e tests here because there are so many little details hidden within those publish commands that we really should invoke those and verify what can be observed somehow.

Copy link

@belgattitude belgattitude Nov 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally. I would be lazy and just unit test something in the lines of

type Params= {
   pm: 'pnpm' | 'yarn' | 'npm',
   pmVersion?: string,
   tag?: string,
   access?: 'public' | 'private',
   otp?: string
}
const getPublishSpawnParams = (params: Params): {cmd: string, args: string[]} => {
 ///....
}

describe('when pnpm', () => {
   it('should return a pnpm..', () => {
      expect(getPublishSpawnParams({pm: 'pnpm', pmVersion: '6.4' tag: 'main', access: 'public', otp: 'xxx'}).toStrictEqual({
          cmd: 'pnpm',
          args: ['publish', '--access', 'public', '--tag', 'main', '--json', '--no-git-checks']
      })
   }); 
}

describe('when yarn < 2', () => {
   it('should return a npm publish..', () => {
      expect(getPublishSpawnParams({pm: 'yarn', pmVersion: '8.0', tag: 'main', access: 'public', otp: 'xxx'}).toStrictEqual({
          cmd: 'npm',
          args: ['publish', '--access', 'public', '--tag', 'main' '--otp', 'xxx']
      })
   }); 
}

describe('when yarn 2+', () => {
   it('should return a yarn..', () => {
      expect(getPublishSpawnParams({pm: 'yarn', pmVersion: '2.4', tag: 'main', access: 'public', otp: 'xxx'}).toStrictEqual({
          cmd: 'yarn',
          args: ['npm', 'publish', '--access', 'public', '--tag', 'main']
      })
   }); 
}

describe('when yarn 3.2', () => {
   it('should return a yarn..', () => {
      expect(getPublishSpawnParams({pm: 'yarn', pmVersion: "3.2', tag: 'main', access: 'public', otp: 'xxx'}).toStrictEqual({
          cmd: 'yarn',
          // I saw that you've opened a --otp option in yarn, so why not ?
          args: ['npm', 'publish', '--access', 'public', '--tag', 'main', '-otp', 'xxx']
      })
   }); 
}

// + some extras 

Not so sure about how to mock spawn, so that might make sense

I'm bad with the names hey :)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm just checking and you're totally right so much small details, that's insane :)

So definitely I would live well with unit tests cause e2e looks a huge effort, especially with minor versions.

I would start with some specs for the most common cases (npm, yarn 1/2+, pnpm 6) and live with a "dirty" function that can be improved later (with things like https://www.npmjs.com/package/compare-versions to adapt the result)

@Andarist
Copy link
Member Author

@belgattitude thank you very much for testing this ❤️ I will make necessary changes here soon, I would only want to land this first #676 so I could rebase this one on top of it.

Andarist and others added 3 commits November 26, 2021 14:31
…e path as argument to the command. This makes sure that it will work with Yarn Berry

Co-authored-by: Sébastien Vanvelthem <belgattitude@users.noreply.github.com>
Co-authored-by: Sébastien Vanvelthem <belgattitude@users.noreply.github.com>
@Andarist
Copy link
Member Author

@belgattitude I've rebased this and applied your suggestions. I've also created a PR to add snapshot releases (here). This can only be merged in when we confirm that it has been done in a secure way, so I don't want to rush it. If you could retest this PR "manually" (like you have done before) - I would highly appreciate it.

@belgattitude
Copy link

belgattitude commented Nov 30, 2021

Hey @Andarist thanks a lot. Here it is

yarn changeset publish
sebastien@seb-thinkpad-x1:~/github/cache-interop$ yarn changeset publish
🦋  warn ===============================IMPORTANT!===============================
🦋  warn You are in prerelease mode so packages will be published to the canary
🦋  warn         dist tag except for packages that have not had normal releases which will be published to latest
🦋  warn ----------------------------------------------------------------------
🦋  info npm info @soluble/cache-interop
🦋  info npm info @soluble/cache-ioredis
🦋  info npm info @soluble/cache-redis
🦋  info npm info @soluble/dsn-parser
🦋  info @soluble/cache-interop is being published because our local version (0.8.2-canary.0) has not been published on npm
🦋  info @soluble/cache-ioredis is being published because our local version (0.8.4-canary.0) has not been published on npm
🦋  info @soluble/cache-redis is being published because our local version (0.8.4-canary.0) has not been published on npm
🦋  info @soluble/dsn-parser is being published because our local version (1.3.3-canary.0) has not been published on npm
🦋  info Publishing "@soluble/cache-interop" at "0.8.2-canary.0"
🦋  info Publishing "@soluble/cache-ioredis" at "0.8.4-canary.0"
🦋  info Publishing "@soluble/cache-redis" at "0.8.4-canary.0"
🦋  info Publishing "@soluble/dsn-parser" at "1.3.3-canary.0"
🦋  info will publish with 'yarn npm publish --access public --tag canary'
🦋  info will publish with 'yarn npm publish --access public --tag canary'
🦋  info will publish with 'yarn npm publish --access public --tag canary'
🦋  info will publish with 'yarn npm publish --access public --tag canary'
🦋  error an error occurred while publishing @soluble/dsn-parser: 
🦋  error ➤ YN0033: No authentication configured for request
🦋  error an error occurred while publishing @soluble/cache-ioredis: 
🦋  error ➤ YN0033: No authentication configured for request
🦋  error an error occurred while publishing @soluble/cache-redis: 
🦋  error ➤ YN0033: No authentication configured for request
🦋  error an error occurred while publishing @soluble/cache-interop: 
🦋  error ➤ YN0033: No authentication configured for request
🦋  error packages failed to publish:
🦋  @soluble/cache-interop@0.8.2-canary.0
🦋  @soluble/cache-ioredis@0.8.4-canary.0
🦋  @soluble/cache-redis@0.8.4-canary.0
🦋  @soluble/dsn-parser@1.3.3-canary.0

The command is not complaining anymore with yarn 3.2.0-rc.6.

🦋 info will publish with 'yarn npm publish --access public --tag canary'

PS: I've added the log here: https://github.com/atlassian/changesets/pull/682/files just to be sure.

That said it's not a full publish, I'll need --otp for that and can only publish my repo from a gh action for now.

So all look fine. My concerns are related to

  • Logging: As it look probable we run into bugs, I would add some logging about the command used.
  • Prevent regressions: I feel we should still have some kind of unit tests for the command (and pm versions). To ensure future changes does not break things.

Of course all is up to you, I come from different ecosystem so my reflexes often lack perspective and love to learn and share in general.

Let me know I still have few free days, and I'll gladly help.

@Andarist
Copy link
Member Author

Logging: As it look probable we run into bugs, I would add some logging about the command used.

That's probably a good call - anything that you would like to see logged beside the information added in your PR here?

Prevent regressions: I feel we should still have some kind of unit tests for the command (and pm versions). To ensure future changes does not break things.

I agree that we need some tests here - I'm just on the fence when it comes to unit tests here. The logic itself is pretty straightforward - the only problem is that I'm not always sure what exact combination of options has to be passed in a specific situation. And when it comes to that I think that comments can be more valuable than unit tests.

I just kinda feel that I would be that more confident about this logic if I would have those unit tests. I'm not a testing expert though and probably many people would say that this would be worthwhile - I'm just not feeling that. Maybe @mitchellhamilton would have some additional opinion about this.

The command is not complaining anymore with yarn 3.2.0-rc.6.

Just to make sure that we are on the same page - everything looks OK now from your end, it's just that publish fails because OTP is required and you are not using the automation token because you are running the publish script locally and not on the CI. Right?

@emmatown
Copy link
Member

testing

tbh, what I'd really like is tests that run with all the different package managers against a local registry (probably https://verdaccio.org) with a way to run the tests against actual registries.

@Andarist
Copy link
Member Author

Andarist commented Dec 1, 2021

Let's move the tests-related discussion to the dedicated thread. I don't think this is sort of out of the scope of this PR and should be addressed separately.

@Andarist Andarist marked this pull request as ready for review December 1, 2021 10:48
@Andarist Andarist requested a review from emmatown December 1, 2021 10:48
@belgattitude
Copy link

belgattitude commented Dec 1, 2021

anything that you would like to see logged beside the information added in your PR here?

New to the project so I don't know but I would follow the concerns from #674 (review) @mitchellhamilton

Just to make sure that we are on the same page - everything looks OK now from your end, it's just that publish fails because OTP is required

Yep. Don't want to mess too much my repo 😄. A canary of changeset can help for me to test it out fully, but as the "yarn command" is okay, I feel I can be confident enough the tool will do its job.

About tests

Just want to express something about unit. I'm certainly not trying to create a debate, just sharing and if it's not useful, please forget.

  1. Don't test what you don't own.

    I like the idea of e2e, but in this specific case I would be confident that:

    • given the package manager cli receive the correct params it will do the job or it's a bug on their side. They have an e2e suite that runs on many os... spawn (which I don't know about) has it's own tests too, I guess. So for me what is to test is the command we run, less the fact that npm, pnpm, yarn 1.2.3 will actually publish what what we are feeding to it. Command line is somehow a contract. Same idea when I test a server, do I need to test if my express server works or just my handler/use-case (business...) .... There's not big difference when you write the tests, but running them is much easier when you don't need to start the actual express server (time, flacky...).
  2. What kind of unit tests

    For example we could list the commands that are expected by each tool (from their doc). To illustrate (I haven't checked just taken from what I read in this P/R)

    Given Expected
    Yarn 1 --otp npm publish --access public --tag main --otp xxx --json
    Yarn 1 npm publish --access public --tag main --json
    Yarn 2 yarn npm publish --access public --tag main
    Yarn 2 --otp should throw 'Unsupported --otp, upgrade to yarn 3.2+'
    Yarn 3.2 --otp yarn npm publish --access public --tag main --otp xxx
    Pnpm 6 yarn npm publish --access public --tag main --json --no-git-checks
    Npm 6 --otp npm publish --access public --tag main --otp xxx --json
    Npm 8 --otp npm publish --access public --tag main --otp xxx --json

    That could be written in many ways... In this scenario looks a fit for kind of acceptance / BDD / cucumber style. either given/when/expect like here or even with a simple it.each and an array of scenarios.

    That should suffice to prevent regressions, as you said the code is trivial but how much variations will come in the future. Personally as a contributor (If I become, I'll become 😄 ) I would be more confident to make changes.

If you like I could add an example test file in a separate P/R... The only thing is that I don't really know node, so a reviewer/guide will be needed 😄

Hey thanks again

@belgattitude
Copy link

@Andarist a little addition. I'm not sure you're aware of it. When I faced the issue with yarn/pnpm workspace: protocol, I just did a quick hack for my own use case. Rather than change npm for publication, I intercepted the pack step (yarn pack, pnpm pack) to generate the archive. see here #585 (comment).

I'm thinking of it now, I guess it's a bit late to tell and I don't want to add confusion here. So feel free to ignore, but it might help to harmonize and have one tool to test for publishing.

@jmatsushita
Copy link

I've been trying out this PR with @qnighy's patch and with yarn 4.0.0-rc.45

I get the following error message when running yarn changeset publish:

🦋  error error while checking if token is required npm ERR! code E401
🦋  error npm ERR! 401 Unauthorized - GET https://registry.npmjs.org/-/npm/v1/user
🦋  error {
🦋  error   "error": {
🦋  error     "code": "E401",
🦋  error     "summary": "401 Unauthorized - GET https://registry.npmjs.org/-/npm/v1/user",
🦋  error     "detail": ""
🦋  error   }
🦋  error }
🦋  error
🦋  error npm ERR! A complete log of this run can be found in:
🦋  error npm ERR!     /Users/jun/.npm/_logs/2023-06-09T10_56_34_917Z-debug-0.log

I traced this to the fact that getTokenIsRequired() is still in the hotpath, but that would require npm profile get --json to work, whereas it's not configured (since yarn uses .yarnrc.yml for registry configuration.

If I simply short circuit this function and return false; then it works. A temporary fix of course, but I hope this would help and maybe give back a tiny bit of momentum to get this over the line. Happy to test new patches!

@jmatsushita
Copy link

jmatsushita commented Jun 10, 2023

Just bumped into the fact that for changesets to check if the package is published in a registry, it uses getPackageInfo which is still using npm info instead of yarn npm info. In the case where registry access is configured only through a .yarnrc.yml file this will fail with a 404 (since it doesn't use the proper registry for that @scope) and will try to republish the package which is already in the registry, and fail if the package was already published (since the yarn npm publish part works correctly).

🦋  warn Received 404 for npm info "@scope/package"
🦋  info @scope/package is being published because our local version (0.0.0) has not been published on npm
🦋  info Publishing "@scope/package" at "0.0.0"
🦋  error an error occurred while publishing @scope/package: 
🦋  error ➤ YN0000: output/Main/index.js
🦋  error ➤ YN0000: package.json
🦋  error ➤ YN0035: The remote server failed to provide the requested resource
🦋  error ➤ YN0035:   Response Code: [40](https://github.com/org/repo/actions/runs/.../jobs/...#step:12:41)3 (Forbidden)
🦋  error ➤ YN0035:   Request Method: PUT
🦋  error ➤ YN0035:   Request URL: https://gitlab.com/api/v4/projects/.../packages/npm/@scope%2fpackage
🦋  error packages failed to publish:
🦋  @scope/package@0.0.0

A similar logic than internalPublish but for yarn npm info works

let { code, stdout, stderr } =
publishTool.name === "yarn"
? await spawn("yarn", ["npm", "publish", ...publishFlags], {
cwd: opts.cwd,
env: Object.assign({}, process.env, envOverride)
})
: await spawn(publishTool.name, ["publish", opts.cwd, ...publishFlags], {
env: Object.assign({}, process.env, envOverride)
});

Here's an updated version of the above patch which works in yarn 4.0.0-rc.45 with these incantations in the package.json:

{
  "name": "package",
  "version": "0.0.0",
  "devDependencies": {
    "@changesets/cli": "patch:@changesets/cli@npm%3A2.26.0#~/.yarn/patches/@changesets-cli-npm-2.26.0-49d5c5f72d.patch"
  },
  "packageManager": "yarn@4.0.0-rc.45",
  "resolutions": {
    "@changesets/cli@^2.26.0": "patch:@changesets/cli@npm%3A2.26.0#./.yarn/patches/@changesets-cli-npm-2.26.0-49d5c5f72d.patch"
  },
}
.yarn/patches/@changesets-cli-npm-2.26.0-49d5c5f72d.patch
diff --git a/dist/cli.cjs.dev.js b/dist/cli.cjs.dev.js
index b1582198d3d2631569ff59f27170354a932d3ad0..2826a10919ca861e82efa105b6604fa52eda9847 100644
--- a/dist/cli.cjs.dev.js
+++ b/dist/cli.cjs.dev.js
@@ -1,5 +1,4 @@
 'use strict';
-
 var meow = require('meow');
 var errors = require('@changesets/errors');
 var logger = require('@changesets/logger');
@@ -705,31 +704,33 @@ function getCorrectRegistry(packageJson) {
   return !registry || registry === "https://registry.yarnpkg.com" ? "https://registry.npmjs.org" : registry;
 }
 
+const getPublishToolVersion = async (name, cwd) => (await spawn__default['default'](name, ["--version"], {
+  cwd
+})).stdout.toString().trim();
+
 async function getPublishTool(cwd) {
-  const pm = await preferredPM__default['default'](cwd);
-  if (!pm || pm.name !== "pnpm") return {
-    name: "npm"
-  };
+  var _await$preferredPM;
 
-  try {
-    let result = await spawn__default['default']("pnpm", ["--version"], {
-      cwd
-    });
-    let version = result.stdout.toString().trim();
-    let parsed = semver__default['default'].parse(version);
-    return {
-      name: "pnpm",
-      shouldAddNoGitChecks: (parsed === null || parsed === void 0 ? void 0 : parsed.major) === undefined ? false : parsed.major >= 5
-    };
-  } catch (e) {
+  const name = ((_await$preferredPM = await preferredPM__default['default'](cwd)) === null || _await$preferredPM === void 0 ? void 0 : _await$preferredPM.name) || "npm";
+  const version = await getPublishToolVersion(name, cwd);
+
+  if (name === "yarn" && semver__default['default'].lt(version, "2.0.0")) {
+    // Yarn Classic doesn't do anything special when publishing, let's stick to the npm client in such a case
     return {
-      name: "pnpm",
-      shouldAddNoGitChecks: false
+      name: "npm",
+      version: await getPublishToolVersion("npm", cwd)
     };
   }
+
+  return {
+    name,
+    version
+  };
 }
 
 async function getTokenIsRequired() {
+  let publishTool = await getPublishTool(process.cwd());
+  if (publishTool.name === "yarn") return false;
   // Due to a super annoying issue in yarn, we have to manually override this env variable
   // See: https://github.com/yarnpkg/yarn/issues/2935#issuecomment-355292633
   const envOverride = {
@@ -754,6 +755,7 @@ async function getTokenIsRequired() {
 }
 function getPackageInfo(packageJson) {
   return npmRequestLimit(async () => {
+    let publishTool = await getPublishTool(process.cwd());
     logger.info(`npm info ${packageJson.name}`); // Due to a couple of issues with yarnpkg, we also want to override the npm registry when doing
     // npm info.
     // Issues: We sometimes get back cached responses, i.e old data about packages which causes
@@ -761,7 +763,9 @@ function getPackageInfo(packageJson) {
     // as they will always give a 404, which will tell `publish` to always try to publish.
     // See: https://github.com/yarnpkg/yarn/issues/2935#issuecomment-355292633
 
-    let result = await spawn__default['default']("npm", ["info", packageJson.name, "--registry", getCorrectRegistry(packageJson), "--json"]); // Github package registry returns empty string when calling npm info
+    let result = publishTool.name === "yarn" ? await spawn__default['default']("yarn", ["npm", "info", packageJson.name, "--json"], {
+      env: Object.assign({}, process.env)
+    }) : await spawn__default['default']("npm", ["info", packageJson.name, "--registry", getCorrectRegistry(packageJson), "--json"]); // Github package registry returns empty string when calling npm info
     // for a non-existent package instead of a E404
 
     if (result.stdout.toString() === "") {
@@ -816,20 +820,32 @@ let getOtpCode = async twoFactorState => {
   }
 
   return askForOtpCode(twoFactorState);
+};
+
+const isOtpError = error => {
+  // The first case is no 2fa provided, the second is when the 2fa is wrong (timeout or wrong words)
+  return error.code === "EOTP" || error.code === "E401" && error.detail.includes("--otp=<code>");
 }; // we have this so that we can do try a publish again after a publish without
 // the call being wrapped in the npm request limit and causing the publishes to potentially never run
 
+
 async function internalPublish(pkgName, opts, twoFactorState) {
   let publishTool = await getPublishTool(opts.cwd);
-  let publishFlags = opts.access ? ["--access", opts.access] : [];
+  let shouldHandleOtp = !isCI__default['default'] && (publishTool.name === "yarn" ? semver__default['default'].gte(publishTool.version, "3.2.0-rc.8") : true);
+  let publishFlags = publishTool.name !== "yarn" ? ["--json"] : [];
+
+  if (opts.access) {
+    publishFlags.push("--access", opts.access);
+  }
+
   publishFlags.push("--tag", opts.tag);
 
-  if ((await twoFactorState.isRequired) && !isCI__default['default']) {
+  if (shouldHandleOtp && (await twoFactorState.isRequired)) {
     let otpCode = await getOtpCode(twoFactorState);
     publishFlags.push("--otp", otpCode);
   }
 
-  if (publishTool.name === "pnpm" && publishTool.shouldAddNoGitChecks) {
+  if (publishTool.name === "pnpm" && semver__default['default'].gte(publishTool.version, "5.0.0")) {
     publishFlags.push("--no-git-checks");
   } // Due to a super annoying issue in yarn, we have to manually override this env variable
   // See: https://github.com/yarnpkg/yarn/issues/2935#issuecomment-355292633
@@ -842,21 +858,35 @@ async function internalPublish(pkgName, opts, twoFactorState) {
     code,
     stdout,
     stderr
-  } = await spawn__default['default'](publishTool.name, ["publish", opts.cwd, "--json", ...publishFlags], {
+  } = publishTool.name === "yarn" ? await spawn__default['default']("yarn", ["npm", "publish", ...publishFlags], {
+    cwd: opts.cwd,
+    env: Object.assign({}, process.env, envOverride)
+  }) : await spawn__default['default'](publishTool.name, ["publish", opts.cwd, ...publishFlags], {
     env: Object.assign({}, process.env, envOverride)
   });
 
   if (code !== 0) {
-    // NPM's --json output is included alongside the `prepublish` and `postpublish` output in terminal
+    // yarn berry doesn't support --json and we don't attempt to parse its output to a machine-readable format
+    if (publishTool.name === "yarn") {
+      const output = stdout.toString().trim().split("\n") // this filters out "unnamed" logs: https://yarnpkg.com/advanced/error-codes/#yn0000---unnamed
+      // this includes a list of packed files and the "summary output" like: "Failed with errors in 0s 75ms"
+      // those are not that interesting so we reduce the noise by dropping them
+      .filter(line => !/YN0000:/.test(line)).join("\n");
+      logger.error(`an error occurred while publishing ${pkgName}:`, `\n${output}`);
+      return {
+        published: false
+      };
+    } // NPM's --json output is included alongside the `prepublish` and `postpublish` output in terminal
     // We want to handle this as best we can but it has some struggles:
     // - output of those lifecycle scripts can contain JSON
     // - npm7 has switched to printing `--json` errors to stderr (https://github.com/npm/cli/commit/1dbf0f9bb26ba70f4c6d0a807701d7652c31d7d4)
     // Note that the `--json` output is always printed at the end so this should work
+
+
     let json = getLastJsonObjectFromString(stderr.toString()) || getLastJsonObjectFromString(stdout.toString());
 
     if (json !== null && json !== void 0 && json.error) {
-      // The first case is no 2fa provided, the second is when the 2fa is wrong (timeout or wrong words)
-      if ((json.error.code === "EOTP" || json.error.code === "E401" && json.error.detail.includes("--otp=<code>")) && !isCI__default['default']) {
+      if (shouldHandleOtp && isOtpError(json.error)) {
         if (twoFactorState.token !== null) {
           // the current otp code must be invalid since it errored
           twoFactorState.token = null;
diff --git a/dist/cli.cjs.prod.js b/dist/cli.cjs.prod.js
index 5b1b7dd6439be4f0b721a17e683b1435c254f5b8..803762c16b9c7f484948171e42e1d8b4d95cbc7c 100644
--- a/dist/cli.cjs.prod.js
+++ b/dist/cli.cjs.prod.js
@@ -1,5 +1,5 @@
 "use strict";
-
+console.log()
 var meow = require("meow"), errors = require("@changesets/errors"), logger = require("@changesets/logger"), util = require("util"), fs = require("fs-extra"), path = require("path"), getPackages = require("@manypkg/get-packages"), getDependentsGraph = require("@changesets/get-dependents-graph"), config = require("@changesets/config"), chalk = require("chalk"), child_process = require("child_process"), termSize = require("term-size"), enquirer = require("enquirer"), externalEditor = require("external-editor"), ansiColors = require("ansi-colors"), git = require("@changesets/git"), writeChangeset = require("@changesets/write"), resolveFrom = require("resolve-from"), semver = require("semver"), outdent = require("outdent"), applyReleasePlan = require("@changesets/apply-release-plan"), readChangesets = require("@changesets/read"), assembleReleasePlan = require("@changesets/assemble-release-plan"), pre$1 = require("@changesets/pre"), pLimit = require("p-limit"), preferredPM = require("preferred-pm"), spawn = require("spawndamnit"), isCI = require("is-ci"), table = require("tty-table"), getReleasePlan = require("@changesets/get-release-plan");
 
 function _interopDefault(e) {
@@ -396,28 +396,25 @@ function getCorrectRegistry(packageJson) {
   return registry && "https://registry.yarnpkg.com" !== registry ? registry : "https://registry.npmjs.org";
 }
 
+const getPublishToolVersion = async (name, cwd) => (await spawn__default.default(name, [ "--version" ], {
+  cwd: cwd
+})).stdout.toString().trim();
+
 async function getPublishTool(cwd) {
-  const pm = await preferredPM__default.default(cwd);
-  if (!pm || "pnpm" !== pm.name) return {
-    name: "npm"
+  var _await$preferredPM;
+  const name = (null === (_await$preferredPM = await preferredPM__default.default(cwd)) || void 0 === _await$preferredPM ? void 0 : _await$preferredPM.name) || "npm", version = await getPublishToolVersion(name, cwd);
+  return "yarn" === name && semver__default.default.lt(version, "2.0.0") ? {
+    name: "npm",
+    version: await getPublishToolVersion("npm", cwd)
+  } : {
+    name: name,
+    version: version
   };
-  try {
-    let version = (await spawn__default.default("pnpm", [ "--version" ], {
-      cwd: cwd
-    })).stdout.toString().trim(), parsed = semver__default.default.parse(version);
-    return {
-      name: "pnpm",
-      shouldAddNoGitChecks: void 0 !== (null == parsed ? void 0 : parsed.major) && parsed.major >= 5
-    };
-  } catch (e) {
-    return {
-      name: "pnpm",
-      shouldAddNoGitChecks: !1
-    };
-  }
 }
 
 async function getTokenIsRequired() {
+  let publishTool = await getPublishTool(process.cwd());
+  if (publishTool.name === "yarn") return false;
   const envOverride = {
     npm_config_registry: getCorrectRegistry()
   };
@@ -432,8 +429,11 @@ async function getTokenIsRequired() {
 
 function getPackageInfo(packageJson) {
   return npmRequestLimit((async () => {
+    let publishTool = await getPublishTool(process.cwd());
     logger.info("npm info " + packageJson.name);
-    let result = await spawn__default.default("npm", [ "info", packageJson.name, "--registry", getCorrectRegistry(packageJson), "--json" ]);
+    let result = "yarn" === publishTool.name ? await spawn__default.default("yarn", [ "npm", "info", packageJson.name, "--json" ], {
+    env: Object.assign({}, process.env)
+  }) : await spawn__default.default("npm", [ "info", packageJson.name, "--registry", getCorrectRegistry(packageJson), "--json" ]);
     return "" === result.stdout.toString() ? {
       error: {
         code: "E404"
@@ -466,23 +466,36 @@ let otpAskLimit = pLimit__default.default(1), askForOtpCode = twoFactorState =>
   return twoFactorState.token = val, val;
 })), getOtpCode = async twoFactorState => null !== twoFactorState.token ? twoFactorState.token : askForOtpCode(twoFactorState);
 
+const isOtpError = error => "EOTP" === error.code || "E401" === error.code && error.detail.includes("--otp=<code>");
+
 async function internalPublish(pkgName, opts, twoFactorState) {
-  let publishTool = await getPublishTool(opts.cwd), publishFlags = opts.access ? [ "--access", opts.access ] : [];
-  if (publishFlags.push("--tag", opts.tag), await twoFactorState.isRequired && !isCI__default.default) {
+  let publishTool = await getPublishTool(opts.cwd), shouldHandleOtp = !isCI__default.default && ("yarn" !== publishTool.name || semver__default.default.gte(publishTool.version, "3.2.0-rc.8")), publishFlags = "yarn" !== publishTool.name ? [ "--json" ] : [];
+  if (opts.access && publishFlags.push("--access", opts.access), publishFlags.push("--tag", opts.tag), 
+  shouldHandleOtp && await twoFactorState.isRequired) {
     let otpCode = await getOtpCode(twoFactorState);
     publishFlags.push("--otp", otpCode);
   }
-  "pnpm" === publishTool.name && publishTool.shouldAddNoGitChecks && publishFlags.push("--no-git-checks");
+  "pnpm" === publishTool.name && semver__default.default.gte(publishTool.version, "5.0.0") && publishFlags.push("--no-git-checks");
   const envOverride = {
     npm_config_registry: getCorrectRegistry()
   };
-  let {code: code, stdout: stdout, stderr: stderr} = await spawn__default.default(publishTool.name, [ "publish", opts.cwd, "--json", ...publishFlags ], {
+  let {code: code, stdout: stdout, stderr: stderr} = "yarn" === publishTool.name ? await spawn__default.default("yarn", [ "npm", "publish", ...publishFlags ], {
+    cwd: opts.cwd,
+    env: Object.assign({}, process.env, envOverride)
+  }) : await spawn__default.default(publishTool.name, [ "publish", opts.cwd, ...publishFlags ], {
     env: Object.assign({}, process.env, envOverride)
   });
   if (0 !== code) {
+    if ("yarn" === publishTool.name) {
+      const output = stdout.toString().trim().split("\n").filter((line => !/YN0000:/.test(line))).join("\n");
+      return logger.error(`an error occurred while publishing ${pkgName}:`, "\n" + output), 
+      {
+        published: !1
+      };
+    }
     let json = getLastJsonObjectFromString(stderr.toString()) || getLastJsonObjectFromString(stdout.toString());
     if (null != json && json.error) {
-      if (("EOTP" === json.error.code || "E401" === json.error.code && json.error.detail.includes("--otp=<code>")) && !isCI__default.default) return null !== twoFactorState.token && (twoFactorState.token = null), 
+      if (shouldHandleOtp && ("EOTP" === (error = json.error).code || "E401" === error.code && error.detail.includes("--otp=<code>"))) return null !== twoFactorState.token && (twoFactorState.token = null), 
       twoFactorState.isRequired = Promise.resolve(!0), internalPublish(pkgName, opts, twoFactorState);
       logger.error(`an error occurred while publishing ${pkgName}: ${json.error.code}`, json.error.summary, json.error.detail ? "\n" + json.error.detail : "");
     }
@@ -490,6 +503,7 @@ async function internalPublish(pkgName, opts, twoFactorState) {
       published: !1
     };
   }
+  var error;
   return {
     published: !0
   };
diff --git a/dist/cli.esm.js b/dist/cli.esm.js
index ced46d2520d4bff71b94e4023b1841373a55712b..044d375056d5007859ee1a2e9c3dc7f0c1b4fcd2 100644
--- a/dist/cli.esm.js
+++ b/dist/cli.esm.js
@@ -682,31 +682,33 @@ function getCorrectRegistry(packageJson) {
   return !registry || registry === "https://registry.yarnpkg.com" ? "https://registry.npmjs.org" : registry;
 }
 
+const getPublishToolVersion = async (name, cwd) => (await spawn$1(name, ["--version"], {
+  cwd
+})).stdout.toString().trim();
+
 async function getPublishTool(cwd) {
-  const pm = await preferredPM(cwd);
-  if (!pm || pm.name !== "pnpm") return {
-    name: "npm"
-  };
+  var _await$preferredPM;
 
-  try {
-    let result = await spawn$1("pnpm", ["--version"], {
-      cwd
-    });
-    let version = result.stdout.toString().trim();
-    let parsed = semver.parse(version);
-    return {
-      name: "pnpm",
-      shouldAddNoGitChecks: (parsed === null || parsed === void 0 ? void 0 : parsed.major) === undefined ? false : parsed.major >= 5
-    };
-  } catch (e) {
+  const name = ((_await$preferredPM = await preferredPM(cwd)) === null || _await$preferredPM === void 0 ? void 0 : _await$preferredPM.name) || "npm";
+  const version = await getPublishToolVersion(name, cwd);
+
+  if (name === "yarn" && semver.lt(version, "2.0.0")) {
+    // Yarn Classic doesn't do anything special when publishing, let's stick to the npm client in such a case
     return {
-      name: "pnpm",
-      shouldAddNoGitChecks: false
+      name: "npm",
+      version: await getPublishToolVersion("npm", cwd)
     };
   }
+
+  return {
+    name,
+    version
+  };
 }
 
 async function getTokenIsRequired() {
+  let publishTool = await getPublishTool(process.cwd());
+  if (publishTool.name === "yarn") return false;
   // Due to a super annoying issue in yarn, we have to manually override this env variable
   // See: https://github.com/yarnpkg/yarn/issues/2935#issuecomment-355292633
   const envOverride = {
@@ -731,6 +733,7 @@ async function getTokenIsRequired() {
 }
 function getPackageInfo(packageJson) {
   return npmRequestLimit(async () => {
+    let publishTool = await getPublishTool(process.cwd());
     info(`npm info ${packageJson.name}`); // Due to a couple of issues with yarnpkg, we also want to override the npm registry when doing
     // npm info.
     // Issues: We sometimes get back cached responses, i.e old data about packages which causes
@@ -738,7 +741,9 @@ function getPackageInfo(packageJson) {
     // as they will always give a 404, which will tell `publish` to always try to publish.
     // See: https://github.com/yarnpkg/yarn/issues/2935#issuecomment-355292633
 
-    let result = await spawn$1("npm", ["info", packageJson.name, "--registry", getCorrectRegistry(packageJson), "--json"]); // Github package registry returns empty string when calling npm info
+    let result = publishTool.name === "yarn" ? await spawn$1("yarn", ["npm", "info", packageJson.name, "--json"], {
+    env: Object.assign({}, process.env)
+  }) : await spawn$1("npm", ["info", packageJson.name, "--registry", getCorrectRegistry(packageJson), "--json"]); // Github package registry returns empty string when calling npm info
     // for a non-existent package instead of a E404
 
     if (result.stdout.toString() === "") {
@@ -793,20 +798,32 @@ let getOtpCode = async twoFactorState => {
   }
 
   return askForOtpCode(twoFactorState);
+};
+
+const isOtpError = error => {
+  // The first case is no 2fa provided, the second is when the 2fa is wrong (timeout or wrong words)
+  return error.code === "EOTP" || error.code === "E401" && error.detail.includes("--otp=<code>");
 }; // we have this so that we can do try a publish again after a publish without
 // the call being wrapped in the npm request limit and causing the publishes to potentially never run
 
+
 async function internalPublish(pkgName, opts, twoFactorState) {
   let publishTool = await getPublishTool(opts.cwd);
-  let publishFlags = opts.access ? ["--access", opts.access] : [];
+  let shouldHandleOtp = !isCI && (publishTool.name === "yarn" ? semver.gte(publishTool.version, "3.2.0-rc.8") : true);
+  let publishFlags = publishTool.name !== "yarn" ? ["--json"] : [];
+
+  if (opts.access) {
+    publishFlags.push("--access", opts.access);
+  }
+
   publishFlags.push("--tag", opts.tag);
 
-  if ((await twoFactorState.isRequired) && !isCI) {
+  if (shouldHandleOtp && (await twoFactorState.isRequired)) {
     let otpCode = await getOtpCode(twoFactorState);
     publishFlags.push("--otp", otpCode);
   }
 
-  if (publishTool.name === "pnpm" && publishTool.shouldAddNoGitChecks) {
+  if (publishTool.name === "pnpm" && semver.gte(publishTool.version, "5.0.0")) {
     publishFlags.push("--no-git-checks");
   } // Due to a super annoying issue in yarn, we have to manually override this env variable
   // See: https://github.com/yarnpkg/yarn/issues/2935#issuecomment-355292633
@@ -819,21 +836,35 @@ async function internalPublish(pkgName, opts, twoFactorState) {
     code,
     stdout,
     stderr
-  } = await spawn$1(publishTool.name, ["publish", opts.cwd, "--json", ...publishFlags], {
+  } = publishTool.name === "yarn" ? await spawn$1("yarn", ["npm", "publish", ...publishFlags], {
+    cwd: opts.cwd,
+    env: Object.assign({}, process.env, envOverride)
+  }) : await spawn$1(publishTool.name, ["publish", opts.cwd, ...publishFlags], {
     env: Object.assign({}, process.env, envOverride)
   });
 
   if (code !== 0) {
-    // NPM's --json output is included alongside the `prepublish` and `postpublish` output in terminal
+    // yarn berry doesn't support --json and we don't attempt to parse its output to a machine-readable format
+    if (publishTool.name === "yarn") {
+      const output = stdout.toString().trim().split("\n") // this filters out "unnamed" logs: https://yarnpkg.com/advanced/error-codes/#yn0000---unnamed
+      // this includes a list of packed files and the "summary output" like: "Failed with errors in 0s 75ms"
+      // those are not that interesting so we reduce the noise by dropping them
+      .filter(line => !/YN0000:/.test(line)).join("\n");
+      error(`an error occurred while publishing ${pkgName}:`, `\n${output}`);
+      return {
+        published: false
+      };
+    } // NPM's --json output is included alongside the `prepublish` and `postpublish` output in terminal
     // We want to handle this as best we can but it has some struggles:
     // - output of those lifecycle scripts can contain JSON
     // - npm7 has switched to printing `--json` errors to stderr (https://github.com/npm/cli/commit/1dbf0f9bb26ba70f4c6d0a807701d7652c31d7d4)
     // Note that the `--json` output is always printed at the end so this should work
+
+
     let json = getLastJsonObjectFromString(stderr.toString()) || getLastJsonObjectFromString(stdout.toString());
 
     if (json !== null && json !== void 0 && json.error) {
-      // The first case is no 2fa provided, the second is when the 2fa is wrong (timeout or wrong words)
-      if ((json.error.code === "EOTP" || json.error.code === "E401" && json.error.detail.includes("--otp=<code>")) && !isCI) {
+      if (shouldHandleOtp && isOtpError(json.error)) {
         if (twoFactorState.token !== null) {
           // the current otp code must be invalid since it errored
           twoFactorState.token = null;
diff --git a/package.json b/package.json
index 0912d0f8fbf6f88c623ba4de0c87345947cde035..4ea6809033e063422e98cc3370685eb9b46847a2 100644
--- a/package.json
+++ b/package.json
@@ -57,7 +57,7 @@
     "meow": "^6.0.0",
     "outdent": "^0.5.0",
     "p-limit": "^2.2.0",
-    "preferred-pm": "^3.0.0",
+    "preferred-pm": "^3.0.3",
     "resolve-from": "^5.0.0",
     "semver": "^5.4.1",
     "spawndamnit": "^2.0.0",

@castastrophe
Copy link

Any interest in picking this work back up? We love changesets at the Adobe Spectrum CSS project but we're leveraging yarn 4.1.1 and the workspaces:^ syntax and would love to see even a small update to using yarn npm publish instead of npm publish so that the workspace identifiers are correctly replaced in the released code.

@tianyingchun
Copy link

any plan of this?

@fmal
Copy link

fmal commented May 26, 2024

I would highly recommend switching to https://github.com/monoweave/monoweave for Yarn-based projects. I had a great experience with this tool, fills all my versioning/publishing needs in a monorepo.

@tianyingchun
Copy link

waiting changesets support workspace:*

@tien
Copy link

tien commented Jul 17, 2024

Hey @Andarist sorry for the ping, I got surprised by this pretty bad today 😅

Are you planning on picking this PR up again? Else, one option I can think of is just to remove changeset publish entirely, and delegate publishing logic to the package manager (could actually be the better way to go).

@HaakonSvane
Copy link

+1

@bensampaio
Copy link

Hey @Andarist, at my company we stumbled upon this issue a few weeks ago and it cost us quite some outages and bugs due to duplicate versions or incorrect resolutions in yarn.lock. I'm surprised to find out that this issue has been there since 2021. It hasn't been fixed and it is also not in the docs anywhere. It could have saved us a lot of headache to know that yarn publish isn't supported. In my opinion, this deserves a warning on your main README file.

Fortunately, we managed to fix this issue with the changes in this PR. So far I didn't get your feedback there. Could you let me know why isn't it an option to merge it?

Most importantly, if you are not fixing this issue, do you have at least a recommendation for people stuck in this problem? At my company, we chose changesets and are willing to help if the effort is manageable. Please let us know if there's anything we can do. And please document this because others should know before they choose this tool.

@kvz
Copy link

kvz commented Nov 13, 2024

Would love to see this get landed. Could @transloadit sponsor this effort perhaps?

@HaakonSvane
Copy link

I'm all for getting this fixed as it is causing us a lot of headaches as well, but this is a good time to reflect on the current state of open source. Developers of open source libraries are getting paid close to nothing and large corporations are profiting from their work. While we wait for this PR to hopefully resolve, please have a look at Dylan Beattie's excellent presentation at NDC and go tell your bosses to help out developers like these.

https://github.com/sponsors/explore

@kvz
Copy link

kvz commented Nov 14, 2024

Is this an ai response? I actually offered to sponsor this. And we allocate 80% of revenue to open source at Transloadit

@HaakonSvane
Copy link

Is this an ai response? I actually offered to sponsor this. And we allocate 80% of revenue to open source at Transloadit

There are obviously more people visiting this PR than just yourself. While it is great to hear about how you help the open source community, this is not very common for companies, especially those where IT is not considered part of their core business, to do. I posted my message in the hopes that other frustrated developers that might come across this PR have a look at the presentation and reflect while we wait

@artyomSultanov
Copy link

+1

@clagos-facephi
Copy link

Having this exact same bug when using workspaces in bun. How can we help to see this fixed?

@imarabinda
Copy link

+1

@paulbalaji
Copy link

+1

@macalinao
Copy link

Hi all, I was able to get Bun workspaces working with changesets.

I wrote a blog post here on my website on how to do this: https://ianm.com/posts/2025-08-18-setting-up-changesets-with-bun-workspaces

@manulera
Copy link

+1 cannot get this to work with yarn

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet