From ed8bfec14c7e891af83c302c5cee52b26abeabb0 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Sat, 19 Sep 2026 12:25:28 +0200 Subject: [PATCH] fix(publish): allow detached head in ci (#15109) Allow publishing from a detached HEAD when the resolved CI setting is enabled. Run the clean-working-tree check first. Branch selection and upstream-history checks apply when HEAD is attached to a branch; outside CI, a detached HEAD continues to fail with ERR_PNPM_GIT_UNKNOWN_BRANCH. Verify detached HEADs with Git before allowing the CI exception; failed lookups do not establish detachment. Keep refused Rust Git metadata fail-closed. Reuse the existing CI configuration in both CLI implementations. This supports workspace release tags without assuming that a tag matches one package's version. Closes pnpm/pnpm#5894. --- .changeset/fix-detached-head-publish.md | 8 ++ pnpm/crates/cli/src/cli_args/publish.rs | 2 +- pnpm/crates/cli/tests/suite/publish.rs | 94 +++++++++++++++++++ pnpm/crates/git-utils/src/lib.rs | 11 +++ pnpm/crates/git-utils/src/tests.rs | 12 ++- pnpm/crates/publish/src/git_checks.rs | 44 ++++++--- pnpm/crates/publish/src/git_checks/tests.rs | 28 ++++-- pnpm11/network/git-utils/src/index.ts | 10 ++ pnpm11/network/git-utils/test/index.test.ts | 9 +- .../releasing/commands/src/publish/publish.ts | 8 +- .../commands/test/publish/gitChecks.ts | 68 +++++++++++++- 11 files changed, 259 insertions(+), 35 deletions(-) create mode 100644 .changeset/fix-detached-head-publish.md diff --git a/.changeset/fix-detached-head-publish.md b/.changeset/fix-detached-head-publish.md new file mode 100644 index 0000000000..cdf0bc78d7 --- /dev/null +++ b/.changeset/fix-detached-head-publish.md @@ -0,0 +1,8 @@ +--- +"@pnpm/releasing.commands": patch +"@pnpm/network.git-utils": patch +"pnpm": patch +"pacquet": patch +--- + +`pnpm publish` now allows a detached Git HEAD in CI, including checkouts of release tags. The working tree must still be clean. Branch and remote-history checks still apply when HEAD is attached [pnpm/pnpm#5894](https://github.com/pnpm/pnpm/issues/5894). diff --git a/pnpm/crates/cli/src/cli_args/publish.rs b/pnpm/crates/cli/src/cli_args/publish.rs index 863379cfec..cf8249bca2 100644 --- a/pnpm/crates/cli/src/cli_args/publish.rs +++ b/pnpm/crates/cli/src/cli_args/publish.rs @@ -165,7 +165,7 @@ impl PublishArgs { // the `git-checks` config setting and the `--no-git-checks` flag. let publish_branch = self.flags.git.publish_branch.as_deref(); let git_checks = config.git_checks && !self.flags.git.no_git_checks; - run_git_checks::(dir, git_checks, publish_branch)?; + run_git_checks::(dir, git_checks, publish_branch, config.ci)?; if recursive { let published = diff --git a/pnpm/crates/cli/tests/suite/publish.rs b/pnpm/crates/cli/tests/suite/publish.rs index 107beed167..4624ffc445 100644 --- a/pnpm/crates/cli/tests/suite/publish.rs +++ b/pnpm/crates/cli/tests/suite/publish.rs @@ -567,3 +567,97 @@ fn publishing_a_nested_project_by_relative_path_keeps_catalog_entries_relative() assert_success(&publish(workspace.path(), &["./projects/nested/bar"])); mock.assert(); } + +#[test] +fn detached_tag_publish_in_ci_preserves_git_checks() { + let dir = tempfile::tempdir().unwrap(); + let mut server = mockito::Server::new(); + write_project( + dir.path(), + &format!("{}/", server.url()), + &json!({ + "name": "test-detached-publish", "version": "1.0.0", + }), + ); + pnpm_testing_utils::git_repo::init_isolated_repo(dir.path()); + for args in [ + vec!["add", "."], + vec!["commit", "-m", "init"], + vec!["tag", "-a", "v1.0.0", "-m", "release", "--no-sign"], + vec!["checkout", "v1.0.0"], + ] { + Command::new("git") + .with_current_dir(dir.path()) + .with_args(args) + .assert() + .success(); + } + + let rejected = pacquet(dir.path()) + .with_env("CI", "false") + .with_env("PNPM_CONFIG_CI", "false") + .with_args(["publish", "--dry-run"]) + .assert() + .failure(); + let stderr = String::from_utf8_lossy(&rejected.get_output().stderr); + assert!(stderr.contains("ERR_PNPM_GIT_UNKNOWN_BRANCH"), "stderr: {stderr}"); + + let uploaded = server + .mock("PUT", "/test-detached-publish") + .match_body(Matcher::PartialJson(json!({"dist-tags": {"latest": "1.0.0"}}))) + .with_status(200) + .with_body(r#"{"ok":true}"#) + .expect(1) + .create(); + pacquet(dir.path()) + .with_env("CI", "true") + .without_env("PNPM_CONFIG_CI") + .with_args(["publish", "--publish-branch", "release"]) + .assert() + .success(); + uploaded.assert(); + + fs::write(dir.path().join("LICENSE"), "uncommitted").unwrap(); + let rejected = pacquet(dir.path()) + .with_env("CI", "true") + .without_env("PNPM_CONFIG_CI") + .with_args(["publish", "--dry-run"]) + .assert() + .failure(); + let stderr = String::from_utf8_lossy(&rejected.get_output().stderr); + assert!(stderr.contains("ERR_PNPM_GIT_UNCLEAN"), "stderr: {stderr}"); +} + +#[cfg(unix)] +#[test] +fn publish_rejects_refused_head_metadata_in_ci() { + let dir = tempfile::tempdir().unwrap(); + write_project( + dir.path(), + "http://127.0.0.1:1/", + &json!({ + "name": "test-refused-head", "version": "1.0.0", + }), + ); + pnpm_testing_utils::git_repo::init_isolated_repo(dir.path()); + for args in [vec!["add", "."], vec!["commit", "-m", "init"], vec!["checkout", "-b", "blocked"]] + { + Command::new("git") + .with_current_dir(dir.path()) + .with_args(args) + .assert() + .success(); + } + let git_dir = dir.path().join(".git"); + fs::remove_file(git_dir.join("HEAD")).unwrap(); + std::os::unix::fs::symlink("refs/heads/blocked", git_dir.join("HEAD")).unwrap(); + + let rejected = pacquet(dir.path()) + .with_env("CI", "true") + .without_env("PNPM_CONFIG_CI") + .with_args(["publish", "--dry-run"]) + .assert() + .failure(); + let stderr = String::from_utf8_lossy(&rejected.get_output().stderr); + assert!(stderr.contains("ERR_PNPM_GIT_UNKNOWN_BRANCH"), "stderr: {stderr}"); +} diff --git a/pnpm/crates/git-utils/src/lib.rs b/pnpm/crates/git-utils/src/lib.rs index 4ade8ded97..11400c2b25 100644 --- a/pnpm/crates/git-utils/src/lib.rs +++ b/pnpm/crates/git-utils/src/lib.rs @@ -57,6 +57,17 @@ pub fn get_current_branch(cwd: &Path) -> Option { } } +/// Verify that HEAD resolves to a detached commit. Refused metadata and failed +/// Git queries are not treated as detached. +#[must_use] +pub fn is_head_detached(cwd: &Path) -> bool { + if matches!(read_branch_from_head_file(cwd), HeadBranch::Branch(_) | HeadBranch::Refused) { + return false; + } + Sys::run("git", &["rev-parse", "--verify", "--symbolic-full-name", "HEAD"], Some(cwd)) + .is_ok_and(|output| output.success && output.stdout.trim() == "HEAD") +} + /// The outcomes of reading `.git/HEAD`. enum HeadBranch { Branch(String), diff --git a/pnpm/crates/git-utils/src/tests.rs b/pnpm/crates/git-utils/src/tests.rs index fba1cdbda4..fc1bc1f766 100644 --- a/pnpm/crates/git-utils/src/tests.rs +++ b/pnpm/crates/git-utils/src/tests.rs @@ -1,4 +1,4 @@ -use super::{CommandOutput, RunCommand, get_current_branch}; +use super::{CommandOutput, RunCommand, get_current_branch, is_head_detached}; use std::{fs, io, path::Path}; use tempfile::TempDir; @@ -111,6 +111,7 @@ fn a_head_that_is_not_a_plain_file_is_not_read() { std::os::unix::fs::symlink(&target, repo.join(".git/HEAD")).unwrap(); assert_eq!(get_current_branch::(&repo), None); + assert!(!is_head_detached::(&repo), "refused metadata must not be queried by Git"); } /// A FIFO at `HEAD` must be refused rather than opened: a plain `open` @@ -160,3 +161,12 @@ fn make_fifo(path: &std::path::Path) { .expect("run mkfifo"); assert!(status.success(), "mkfifo failed"); } + +#[test] +fn a_failed_head_verification_is_not_detached() { + let repo = repo_with_head("0123456789abcdef0123456789abcdef01234567\n"); + assert!( + !is_head_detached::(repo.path()), + "a failed Git query must not confirm detachment", + ); +} diff --git a/pnpm/crates/publish/src/git_checks.rs b/pnpm/crates/publish/src/git_checks.rs index 171cb3c0be..0be91dae19 100644 --- a/pnpm/crates/publish/src/git_checks.rs +++ b/pnpm/crates/publish/src/git_checks.rs @@ -5,7 +5,8 @@ use std::path::Path; use pnpm_diagnostics::miette::{self, Diagnostic}; use pnpm_git_utils::{ - get_current_branch, is_git_repo, is_remote_history_clean, is_working_tree_clean, + get_current_branch, is_git_repo, is_head_detached, is_remote_history_clean, + is_working_tree_clean, }; use crate::capabilities::{ConfirmPrompt, RunCommand}; @@ -13,11 +14,13 @@ use crate::capabilities::{ConfirmPrompt, RunCommand}; const GIT_CHECKS_HINT: &str = r#"If you want to disable Git checks on publish, set the "git-checks" setting to "false", or run again with "--no-git-checks"."#; /// Run the publish git checks for `cwd`. A no-op when `git_checks_enabled` is -/// false or `cwd` is not a git repository. +/// false or `cwd` is not a git repository. A detached HEAD is allowed in CI +/// after checking that the working tree is clean. pub fn run_git_checks( cwd: &Path, git_checks_enabled: bool, publish_branch: Option<&str>, + ci: bool, ) -> Result<(), GitCheckError> where Sys: RunCommand + ConfirmPrompt, @@ -34,20 +37,13 @@ where Some(branch) => vec![branch.to_owned()], None => vec!["master".to_owned(), "main".to_owned()], }; - let branches_display = branches.join("|"); - - let Some(current_branch) = get_current_branch::(cwd) else { - return Err(GitCheckError::UnknownBranch { branches: branches_display }); + let current_branch = match get_current_branch::(cwd) { + Some(branch) => branch, + None if ci && is_head_detached::(cwd) => return Ok(()), + None => return Err(GitCheckError::UnknownBranch { branches: branches.join("|") }), }; - if !branches.contains(¤t_branch) { - let message = format!( - r#"You're on branch "{current_branch}" but your "publish-branch" is set to "{branches_display}". Do you want to continue?"#, - ); - if !Sys::confirm(&message) { - return Err(GitCheckError::NotCorrectBranch { branches: branches_display }); - } - } + check_publish_branch::(¤t_branch, &branches)?; if !is_remote_history_clean::(cwd) { return Err(GitCheckError::NotLatest); @@ -56,6 +52,26 @@ where Ok(()) } +fn check_publish_branch( + current_branch: &str, + branches: &[String], +) -> Result<(), GitCheckError> { + if branches + .iter() + .any(|branch| branch == current_branch) + { + return Ok(()); + } + let branches_display = branches.join("|"); + let message = format!( + r#"You're on branch "{current_branch}" but your "publish-branch" is set to "{branches_display}". Do you want to continue?"#, + ); + if !Sys::confirm(&message) { + return Err(GitCheckError::NotCorrectBranch { branches: branches_display }); + } + Ok(()) +} + /// The git working-tree precondition that failed. Each variant is an /// `ERR_PNPM_GIT_*` publish error carrying the same disable-checks hint. #[derive(Debug, derive_more::Display, derive_more::Error, Diagnostic)] diff --git a/pnpm/crates/publish/src/git_checks/tests.rs b/pnpm/crates/publish/src/git_checks/tests.rs index d5738c16d9..1076672031 100644 --- a/pnpm/crates/publish/src/git_checks/tests.rs +++ b/pnpm/crates/publish/src/git_checks/tests.rs @@ -32,7 +32,7 @@ fn skips_when_disabled() { unreachable!() } } - assert!(run_git_checks::(Path::new("/"), false, None).is_ok()); + assert!(run_git_checks::(Path::new("/"), false, None, false).is_ok()); } #[test] @@ -49,7 +49,7 @@ fn skips_when_not_a_git_repo() { unreachable!() } } - assert!(run_git_checks::(Path::new("/"), true, None).is_ok()); + assert!(run_git_checks::(Path::new("/"), true, None, false).is_ok()); } #[test] @@ -69,16 +69,21 @@ fn errors_on_unclean_tree() { unreachable!() } } - let err = run_git_checks::(Path::new("/"), true, None).unwrap_err(); - assert!(matches!(err, GitCheckError::Unclean)); + for ci in [false, true] { + let err = run_git_checks::(Path::new("/"), true, None, ci).unwrap_err(); + assert!(matches!(dbg!(err), GitCheckError::Unclean)); + } } #[test] -fn errors_on_detached_head() { +fn allows_detached_head_only_in_ci() { let repo = repo_with_head("0123456789abcdef0123456789abcdef01234567\n"); struct Sys; impl RunCommand for Sys { fn run(_: &str, args: &[&str], _: Option<&Path>) -> io::Result { + if args == ["rev-parse", "--verify", "--symbolic-full-name", "HEAD"] { + return ok("HEAD\n"); + } match args[0] { "rev-parse" => ok(""), "status" => ok(""), @@ -91,8 +96,9 @@ fn errors_on_detached_head() { unreachable!() } } - let err = run_git_checks::(repo.path(), true, None).unwrap_err(); - assert!(matches!(err, GitCheckError::UnknownBranch { .. })); + let err = run_git_checks::(repo.path(), true, None, false).unwrap_err(); + assert!(matches!(dbg!(err), GitCheckError::UnknownBranch { .. })); + run_git_checks::(repo.path(), true, None, true).unwrap(); } #[test] @@ -113,8 +119,10 @@ fn errors_on_wrong_branch_when_declined() { false } } - let err = run_git_checks::(repo.path(), true, None).unwrap_err(); - assert!(matches!(err, GitCheckError::NotCorrectBranch { .. })); + for ci in [false, true] { + let err = run_git_checks::(repo.path(), true, None, ci).unwrap_err(); + assert!(matches!(dbg!(err), GitCheckError::NotCorrectBranch { .. })); + } } #[test] @@ -136,5 +144,5 @@ fn passes_on_publish_branch_with_clean_remote() { unreachable!("no prompt when already on a publish branch") } } - assert!(run_git_checks::(repo.path(), true, None).is_ok()); + assert!(run_git_checks::(repo.path(), true, None, false).is_ok()); } diff --git a/pnpm11/network/git-utils/src/index.ts b/pnpm11/network/git-utils/src/index.ts index fa5c701f41..bace4227fd 100644 --- a/pnpm11/network/git-utils/src/index.ts +++ b/pnpm11/network/git-utils/src/index.ts @@ -30,6 +30,16 @@ export async function getCurrentBranch (opts: GitCwdOptions = {}): Promise { + try { + const { stdout } = await execa('git', ['rev-parse', '--verify', '--symbolic-full-name', 'HEAD'], { cwd: opts.cwd }) + return stdout === 'HEAD' + } catch { + return false + } +} + export async function isWorkingTreeClean (opts: GitCwdOptions = {}): Promise { try { const { stdout: status } = await execa('git', ['status', '--porcelain'], { cwd: opts.cwd }) diff --git a/pnpm11/network/git-utils/test/index.test.ts b/pnpm11/network/git-utils/test/index.test.ts index 59a6511267..79b464b3d4 100644 --- a/pnpm11/network/git-utils/test/index.test.ts +++ b/pnpm11/network/git-utils/test/index.test.ts @@ -2,7 +2,7 @@ import fs from 'node:fs' import path from 'node:path' import { expect, test } from '@jest/globals' -import { getCurrentBranch, isGitRepo, isWorkingTreeClean } from '@pnpm/network.git-utils' +import { getCurrentBranch, isGitRepo, isHeadDetached, isWorkingTreeClean } from '@pnpm/network.git-utils' import { safeExeca as execa } from 'execa' import { temporaryDirectory } from 'tempy' @@ -25,6 +25,7 @@ test('getCurrentBranch', async () => { await execa('git', ['checkout', '-b', 'foo']) await expect(getCurrentBranch()).resolves.toBe('foo') + await expect(isHeadDetached()).resolves.toBe(false) }) test('getCurrentBranch reads branch from .git/HEAD without spawning git', async () => { @@ -45,15 +46,21 @@ test('getCurrentBranch returns null for detached HEAD', async () => { await execa('git', ['config', 'user.name', 'test'], { cwd: tempDir }) await execa('git', ['config', 'commit.gpgsign', 'false'], { cwd: tempDir }) await execa('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: tempDir }) + await expect(isHeadDetached({ cwd: tempDir })).resolves.toBe(false) await execa('git', ['checkout', '--detach', 'HEAD'], { cwd: tempDir }) await expect(getCurrentBranch({ cwd: tempDir })).resolves.toBeNull() + await expect(isHeadDetached({ cwd: tempDir })).resolves.toBe(true) + const subdir = path.join(tempDir, 'subdir') + fs.mkdirSync(subdir) + await expect(isHeadDetached({ cwd: subdir })).resolves.toBe(true) }) test('getCurrentBranch returns null outside a git repo', async () => { const tempDir = temporaryDirectory() await expect(getCurrentBranch({ cwd: tempDir })).resolves.toBeNull() + await expect(isHeadDetached({ cwd: tempDir })).resolves.toBe(false) }) test('isWorkingTreeClean', async () => { diff --git a/pnpm11/releasing/commands/src/publish/publish.ts b/pnpm11/releasing/commands/src/publish/publish.ts index c8156a86b8..ff1361f48e 100644 --- a/pnpm11/releasing/commands/src/publish/publish.ts +++ b/pnpm11/releasing/commands/src/publish/publish.ts @@ -6,7 +6,7 @@ import { docsUrl, readProjectManifest } from '@pnpm/cli.utils' import { type Config, type ConfigContext, types as allTypes } from '@pnpm/config.reader' import { PnpmError } from '@pnpm/error' import { runLifecycleHook, type RunLifecycleHookOptions } from '@pnpm/exec.lifecycle' -import { getCurrentBranch, isGitRepo, isRemoteHistoryClean, isWorkingTreeClean } from '@pnpm/network.git-utils' +import { getCurrentBranch, isGitRepo, isHeadDetached, isRemoteHistoryClean, isWorkingTreeClean } from '@pnpm/network.git-utils' import type { ExportedManifest } from '@pnpm/releasing.exportable-manifest' import type { ProjectManifest } from '@pnpm/types' import { rimraf } from '@zkochan/rimraf' @@ -188,7 +188,7 @@ export async function publish ( } const branches = opts.publishBranch ? [opts.publishBranch] : ['master', 'main'] const currentBranch = await getCurrentBranch() - if (currentBranch === null) { + if (currentBranch === null && !(opts.ci && await isHeadDetached())) { throw new PnpmError( 'GIT_UNKNOWN_BRANCH', `The Git HEAD may not attached to any branch, but your "publish-branch" is set to "${branches.join('|')}".`, @@ -197,7 +197,7 @@ export async function publish ( } ) } - if (!branches.includes(currentBranch)) { + if (currentBranch !== null && !branches.includes(currentBranch)) { let isConfirmed: boolean try { isConfirmed = await confirm({ @@ -217,7 +217,7 @@ export async function publish ( }) } } - if (!(await isRemoteHistoryClean())) { + if (currentBranch !== null && !(await isRemoteHistoryClean())) { throw new PnpmError('GIT_NOT_LATEST', 'Remote history differs. Please pull changes.', { hint: GIT_CHECKS_HINT, }) diff --git a/pnpm11/releasing/commands/test/publish/gitChecks.ts b/pnpm11/releasing/commands/test/publish/gitChecks.ts index 630654837d..bc612100fd 100644 --- a/pnpm11/releasing/commands/test/publish/gitChecks.ts +++ b/pnpm11/releasing/commands/test/publish/gitChecks.ts @@ -30,7 +30,7 @@ const { publish } = await import('@pnpm/releasing.commands') const mockConfirm = jest.mocked(confirm) -test('publish: fails git check if branch is not on master or main', async () => { +test.each([false, true])('publish: fails git check if branch is not on master or main (CI=%s)', async (isCI) => { prepare({ name: 'test-publish-package.json', version: '0.0.0', @@ -47,6 +47,7 @@ test('publish: fails git check if branch is not on master or main', async () => await expect( publish.handler({ ...DEFAULT_OPTS, + ci: isCI, argv: { original: ['publish'] }, dir: process.cwd(), }, []) @@ -55,7 +56,7 @@ test('publish: fails git check if branch is not on master or main', async () => ) }) -test('publish: fails git check if branch is not on specified branch', async () => { +test.each([false, true])('publish: fails git check if branch is not on specified branch (CI=%s)', async (isCI) => { prepare({ name: 'test-publish-package.json', version: '0.0.0', @@ -73,6 +74,7 @@ test('publish: fails git check if branch is not on specified branch', async () = await expect( publish.handler({ ...DEFAULT_OPTS, + ci: isCI, argv: { original: ['publish'] }, dir: process.cwd(), publishBranch: 'latest', @@ -82,7 +84,7 @@ test('publish: fails git check if branch is not on specified branch', async () = ) }) -test('publish: fails git check if branch is not clean', async () => { +test.each([false, true])('publish: fails git check if branch is not clean (CI=%s)', async (isCI) => { prepare({ name: 'test-publish-package.json', version: '0.0.0', @@ -99,6 +101,7 @@ test('publish: fails git check if branch is not clean', async () => { await expect( publish.handler({ ...DEFAULT_OPTS, + ci: isCI, argv: { original: ['publish'] }, dir: process.cwd(), }, []) @@ -107,7 +110,7 @@ test('publish: fails git check if branch is not clean', async () => { ) }) -test('publish: fails git check if branch is not up to date', async () => { +test.each([false, true])('publish: fails git check if branch is not up to date (CI=%s)', async (isCI) => { const remote = temporaryDirectory() prepare({ @@ -129,6 +132,7 @@ test('publish: fails git check if branch is not up to date', async () => { await expect( publish.handler({ ...DEFAULT_OPTS, + ci: isCI, argv: { original: ['publish'] }, dir: process.cwd(), }, []) @@ -154,6 +158,7 @@ test('publish: fails git check if HEAD is detached', async () => { await expect( publish.handler({ ...DEFAULT_OPTS, + ci: false, argv: { original: ['publish'] }, dir: process.cwd(), }, []) @@ -161,3 +166,58 @@ test('publish: fails git check if HEAD is detached', async () => { new PnpmError('GIT_UNKNOWN_BRANCH', 'The Git HEAD may not attached to any branch, but your "publish-branch" is set to "master|main".') ) }) + +test.each([undefined, 'release'])('publish: allows a detached tag in CI (publishBranch=%s)', async (publishBranch) => { + await prepareDetachedTag() + mockConfirm.mockClear() + + const result = await publish.handler({ + ...DEFAULT_OPTS, + ci: true, + argv: { original: ['publish'] }, + dir: process.cwd(), + dryRun: true, + publishBranch, + json: true, + }, []) + + expect(JSON.parse(result!.output!)).toMatchObject({ name: 'test-publish-package.json', version: '0.0.0' }) + expect(mockConfirm).not.toHaveBeenCalled() +}) + +test('publish: rejects a dirty detached tag in CI', async () => { + await prepareDetachedTag() + fs.writeFileSync('LICENSE', 'workspace license', 'utf8') + + await expect(publish.handler({ + ...DEFAULT_OPTS, + ci: true, + argv: { original: ['publish'] }, + dir: process.cwd(), + dryRun: true, + }, [])).rejects.toThrow(new PnpmError('GIT_UNCLEAN', 'Unclean working tree. Commit or stash changes first.')) +}) + +test('publish: rejects an unknown symbolic HEAD in CI', async () => { + await prepareDetachedTag() + await execa('git', ['symbolic-ref', 'HEAD', 'refs/tags/v0.0.0']) + + await expect(publish.handler({ + ...DEFAULT_OPTS, + ci: true, + argv: { original: ['publish'] }, + dir: process.cwd(), + dryRun: true, + }, [])).rejects.toThrow(new PnpmError('GIT_UNKNOWN_BRANCH', 'The Git HEAD may not attached to any branch, but your "publish-branch" is set to "master|main".')) +}) + +async function prepareDetachedTag (): Promise { + prepare({ name: 'test-publish-package.json', version: '0.0.0' }) + await execa('git', ['init', '--initial-branch=main']) + await execa('git', ['config', 'user.email', 'x@y.z']) + await execa('git', ['config', 'user.name', 'xyz']) + await execa('git', ['add', '*']) + await execa('git', ['commit', '-m', 'init', '--no-gpg-sign']) + await execa('git', ['tag', '-a', 'v0.0.0', '-m', 'release', '--no-sign']) + await execa('git', ['checkout', 'v0.0.0']) +}