Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ $ np --help
Options
--any-branch Allow publishing from any branch
--branch Name of the release branch (default: main | master)
--allow-dirty Allow publishing when the working tree is dirty
--no-cleanup Skips np's node_modules cleanup step before install
--no-tests Skips tests
--yolo Skips cleanup and testing
Expand Down
45 changes: 29 additions & 16 deletions source/cli-implementation.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const cli = meow(`
Options
--any-branch Allow publishing from any branch
--branch Name of the release branch (default: main | master)
--allow-dirty Allow publishing when the working tree is dirty
--no-cleanup Skips np's node_modules cleanup step before install
--no-tests Skips tests
--yolo Skips cleanup and testing
Expand Down Expand Up @@ -64,6 +65,9 @@ const cli = meow(`
branch: {
type: 'string',
},
allowDirty: {
type: 'boolean',
},
cleanup: {
type: 'boolean',
},
Expand Down Expand Up @@ -178,10 +182,16 @@ async function getOptions() {
const version = flags.releaseDraftOnly ? package_.version : cli.input.at(0);

const branch = flags.branch ?? await git.defaultBranch();

if (!flags.releaseDraftOnly) {
// Keep obvious Git failures ahead of the wizard, but do not replace the later Git task.
// The publish flow still needs a final check in case the repo changes while the user is prompting or logging in.
await verifyGitTasks({anyBranch: flags.anyBranch, branch, remote: flags.remote});
await verifyGitTasks({
anyBranch: flags.anyBranch,
allowDirty: flags.allowDirty,
branch,
remote: flags.remote,
});
}

const options = await ui({
Expand All @@ -201,6 +211,23 @@ async function getOptions() {
};
}

async function verifyPublishAuth(package_) {
const externalRegistry = npm.isExternalRegistry(package_)
? package_.publishConfig.registry
: false;

try {
await npm.username({externalRegistry});
} catch (error) {
if (error.isNotLoggedIn && isInteractive()) {
console.log('\nYou must be logged in to publish. Running `npm login`...\n');
await npm.login({externalRegistry});
} else {
throw error;
}
}
}

try {
const {options, projectDirectory, rootDirectory, package_} = await getOptions();

Expand All @@ -210,24 +237,10 @@ try {

// Check authentication early, before Listr starts (so login can be interactive)
if (options.runPublish) {
// Skip auth check if OIDC is available (will be handled by npm publish itself)
if (getOidcProvider()) {
console.log('OIDC authentication detected - skipping auth check');
} else {
const externalRegistry = npm.isExternalRegistry(package_)
? package_.publishConfig.registry
: false;

try {
await npm.username({externalRegistry});
} catch (error) {
if (error.isNotLoggedIn && isInteractive()) {
console.log('\nYou must be logged in to publish. Running `npm login`...\n');
await npm.login({externalRegistry});
} else {
throw error;
}
}
await verifyPublishAuth(package_);
}
}

Expand Down
12 changes: 11 additions & 1 deletion source/git-tasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ const createGitTasks = options => {
tasks.shift();
}

if (options.allowDirty) {
const index = tasks.findIndex(task => task.title === 'Check local working tree');
if (index !== -1) {
tasks.splice(index, 1);
}
}

return tasks;
};

Expand All @@ -29,7 +36,10 @@ export const verifyGitTasks = async options => {
await git.verifyCurrentBranchIsReleaseBranch(options.branch);
}

await git.verifyWorkingTreeIsClean();
if (!options.allowDirty) {
await git.verifyWorkingTreeIsClean();
}

if (options.remote) {
await git.verifyRemoteIsValid(options.remote);
} else if (
Expand Down
10 changes: 6 additions & 4 deletions source/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,12 @@ const np = async (input = 'patch', {packageManager, ...rawOptions}, {package_, p
return exec(...getInstallCommand(), {cwd: projectDirectory});
},
},
{
title: 'Checking working tree is still clean', // If lockfile was out of date and tracked by git, this will fail
task: () => git.verifyWorkingTreeIsClean(),
},
...options.allowDirty
? []
: [{
title: 'Checking working tree is still clean', // If lockfile was out of date and tracked by git, this will fail
task: () => git.verifyWorkingTreeIsClean(),
}],
]),
},
{
Expand Down
15 changes: 13 additions & 2 deletions test/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ test('flags: --help', cliPasses, cli, '--help', [
'Options',
'--any-branch Allow publishing from any branch',
'--branch Name of the release branch (default: main | master)',
'--allow-dirty Allow publishing when the working tree is dirty',
'--no-cleanup Skips np\'s node_modules cleanup step before install',
'--no-tests Skips tests',
'--yolo Skips cleanup and testing',
Expand Down Expand Up @@ -139,7 +140,12 @@ test.serial('cli runs git preflight before prompting', async t => {
},
});

t.true(verifyGitTasksStub.calledOnceWithExactly({anyBranch: undefined, branch: 'main', remote: undefined}));
t.true(verifyGitTasksStub.calledOnceWithExactly({
anyBranch: undefined,
allowDirty: undefined,
branch: 'main',
remote: undefined,
}));
t.true(uiStub.notCalled);
t.true(gracefulExitStub.calledOnceWithExactly(1));

Expand All @@ -163,7 +169,12 @@ test.serial('cli continues to the publish flow after successful git preflight',
},
});

t.true(verifyGitTasksStub.calledOnceWithExactly({anyBranch: undefined, branch: 'main', remote: undefined}));
t.true(verifyGitTasksStub.calledOnceWithExactly({
anyBranch: undefined,
allowDirty: undefined,
branch: 'main',
remote: undefined,
}));
t.true(uiStub.calledOnce);
t.true(npStub.calledOnce);
t.false('skipGitTasks' in npStub.firstCall.args[1]);
Expand Down
56 changes: 56 additions & 0 deletions test/tasks/git-tasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,29 @@ test.serial('should fail when local working tree modified', createFixture, [
assertTaskFailed(t, 'Check local working tree');
});

test.serial('should not fail when local working tree modified and allow-dirty is enabled', createFixture, [
{
command: 'git symbolic-ref --short HEAD',
stdout: 'master',
},
{
command: 'git rev-parse @{u}',
exitCode: 0,
},
{
command: 'git fetch --dry-run',
exitCode: 0,
},
{
command: 'git rev-list --count --left-only @{u}...HEAD',
stdout: '0',
},
], async ({t, testedModule: gitTasks}) => {
await t.notThrowsAsync(run(gitTasks({branch: 'master', allowDirty: true})));

assertTaskDoesntExist(t, 'Check local working tree');
});

test.serial('should not fail when no remote set up', createFixture, [
{
command: 'git symbolic-ref --short HEAD',
Expand Down Expand Up @@ -206,6 +229,39 @@ test.serial('preflight should validate remote before checking remote history', c
);
});

test.serial('preflight should skip working tree check with allowDirty', createFixture, [
{
command: 'git symbolic-ref --short HEAD',
stdout: 'master',
},
{
command: 'git status --short --branch --porcelain',
stdout: '## master...origin/master',
},
{
command: 'git config branch.master.remote',
stdout: 'origin',
},
{
command: 'git ls-remote origin HEAD',
exitCode: 0,
},
{
command: 'git rev-parse @{u}',
exitCode: 0,
},
{
command: 'git fetch --dry-run',
exitCode: 0,
},
{
command: 'git rev-list --count --left-only @{u}...HEAD',
stdout: '0',
},
], async ({t, testedModule}) => {
await t.notThrowsAsync(testedModule.verifyGitTasks({branch: 'master', allowDirty: true}));
});

test.serial('preflight should skip upstream probe on detached head with anyBranch', createFixture, [
{
command: 'git status --porcelain',
Expand Down