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:
1 parent
ea9f3bf4a0
commit
ed8bfec14c
11 files changed
+259
-35
No files matched your search
@@ -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).
|
||||
@@ -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 =
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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(¤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::<Sys>(¤t_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)]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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'])
|
||||
}
|
||||
Reference in new issue
Block a user