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.
This commit is contained in:
Zoltan Kochan authored and GitHub committed 2026-09-19 12:25:28 +02:00
1 parent ea9f3bf4a0
commit ed8bfec14c
11 files changed
+259 -35

No files matched your search

+8
View File
@@ -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).
+1 -1
View File
@@ -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::<Host>(dir, git_checks, publish_branch)?;
run_git_checks::<Host>(dir, git_checks, publish_branch, config.ci)?;
if recursive {
let published =
+94
View File
@@ -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}");
}
+11
View File
@@ -57,6 +57,17 @@ pub fn get_current_branch<Sys: RunCommand>(cwd: &Path) -> Option<String> {
}
}
/// 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<Sys: RunCommand>(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),
+11 -1
View File
@@ -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::<GitFails>(&repo), None);
assert!(!is_head_detached::<NoGit>(&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::<GitFails>(repo.path()),
"a failed Git query must not confirm detachment",
);
}
+30 -14
View File
@@ -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<Sys>(
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::<Sys>(cwd) else {
return Err(GitCheckError::UnknownBranch { branches: branches_display });
let current_branch = match get_current_branch::<Sys>(cwd) {
Some(branch) => branch,
None if ci && is_head_detached::<Sys>(cwd) => return Ok(()),
None => return Err(GitCheckError::UnknownBranch { branches: branches.join("|") }),
};
if !branches.contains(&current_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::<Sys>(&current_branch, &branches)?;
if !is_remote_history_clean::<Sys>(cwd) {
return Err(GitCheckError::NotLatest);
@@ -56,6 +52,26 @@ where
Ok(())
}
fn check_publish_branch<Sys: ConfirmPrompt>(
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)]
+18 -10
View File
@@ -32,7 +32,7 @@ fn skips_when_disabled() {
unreachable!()
}
}
assert!(run_git_checks::<Sys>(Path::new("/"), false, None).is_ok());
assert!(run_git_checks::<Sys>(Path::new("/"), false, None, false).is_ok());
}
#[test]
@@ -49,7 +49,7 @@ fn skips_when_not_a_git_repo() {
unreachable!()
}
}
assert!(run_git_checks::<Sys>(Path::new("/"), true, None).is_ok());
assert!(run_git_checks::<Sys>(Path::new("/"), true, None, false).is_ok());
}
#[test]
@@ -69,16 +69,21 @@ fn errors_on_unclean_tree() {
unreachable!()
}
}
let err = run_git_checks::<Sys>(Path::new("/"), true, None).unwrap_err();
assert!(matches!(err, GitCheckError::Unclean));
for ci in [false, true] {
let err = run_git_checks::<Sys>(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<CommandOutput> {
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::<Sys>(repo.path(), true, None).unwrap_err();
assert!(matches!(err, GitCheckError::UnknownBranch { .. }));
let err = run_git_checks::<Sys>(repo.path(), true, None, false).unwrap_err();
assert!(matches!(dbg!(err), GitCheckError::UnknownBranch { .. }));
run_git_checks::<Sys>(repo.path(), true, None, true).unwrap();
}
#[test]
@@ -113,8 +119,10 @@ fn errors_on_wrong_branch_when_declined() {
false
}
}
let err = run_git_checks::<Sys>(repo.path(), true, None).unwrap_err();
assert!(matches!(err, GitCheckError::NotCorrectBranch { .. }));
for ci in [false, true] {
let err = run_git_checks::<Sys>(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::<Sys>(repo.path(), true, None).is_ok());
assert!(run_git_checks::<Sys>(repo.path(), true, None, false).is_ok());
}
+10
View File
@@ -30,6 +30,16 @@ export async function getCurrentBranch (opts: GitCwdOptions = {}): Promise<strin
}
}
/** Returns false when Git cannot verify HEAD or HEAD refers to a branch. */
export async function isHeadDetached (opts: GitCwdOptions = {}): Promise<boolean> {
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<boolean> {
try {
const { stdout: status } = await execa('git', ['status', '--porcelain'], { cwd: opts.cwd })
+8 -1
View File
@@ -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 () => {
@@ -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,
})
@@ -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<void> {
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'])
}