Skip to content

Commit

Permalink
feat(publish): recover from network failure (#3513)
Browse files Browse the repository at this point in the history
  • Loading branch information
fahslaj committed Feb 5, 2023
1 parent 724633c commit f03ee3e
Show file tree
Hide file tree
Showing 7 changed files with 166 additions and 64 deletions.
117 changes: 117 additions & 0 deletions e2e/publish/src/from-git-recover-from-error.spec.ts
@@ -0,0 +1,117 @@
import { Fixture, normalizeCommitSHAs, normalizeEnvironment } from "@lerna/e2e-utils";

const randomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min;
const randomVersion = () => `${randomInt(10, 89)}.${randomInt(10, 89)}.${randomInt(10, 89)}`;

expect.addSnapshotSerializer({
serialize(str: string) {
return normalizeCommitSHAs(normalizeEnvironment(str))
.replaceAll(/integrity:\s*.*/g, "integrity: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX")
.replaceAll(/\d*B package\.json/g, "XXXB package.json")
.replaceAll(/size:\s*\d*\s?B/g, "size: XXXB")
.replaceAll(/\d*\.\d*\s?kB/g, "XXX.XXX kb");
},
test(val: string) {
return val != null && typeof val === "string";
},
});

describe("lerna-publish-from-git-recover-from-error", () => {
let fixture: Fixture;

beforeEach(async () => {
fixture = await Fixture.create({
e2eRoot: process.env.E2E_ROOT,
name: "lerna-publish-from-git-recover-from-error",
packageManager: "npm",
initializeGit: true,
runLernaInit: true,
installDependencies: true,
});
});
afterEach(() => fixture.destroy());

describe("from-git", () => {
it("should publish remaining packages to the remote registry even if one has already been published", async () => {
await fixture.lerna("create test-1 -y");
await fixture.createInitialGitCommit();
await fixture.exec("git push --set-upstream origin test-main");

const version = randomVersion();
await fixture.lerna(`version ${version} -y`);
await fixture.lerna("publish from-git --registry=http://localhost:4872 -y");

// set up a scenario where one package needs to be published but the
// other has already been published.
await fixture.lerna("create test-2 -y");
await fixture.exec("git add . && git commit --amend --no-edit");
await fixture.exec("git push -f");
await fixture.exec(`git tag -d v${version}`);
await fixture.exec(`git push --delete origin v${version}`);
await fixture.exec(`git tag -a v${version} -m v${version}`);
await fixture.exec("git push --tags");

// publish both packages again, with test-1 failing
// because it is already published
const output = await fixture.lerna("publish from-git --registry=http://localhost:4872 -y");
const unpublishOutput1 = await fixture.exec(
`npm unpublish --force test-1@${version} --registry=http://localhost:4872`
);
const unpublishOutput2 = await fixture.exec(
`npm unpublish --force test-2@${version} --registry=http://localhost:4872`
);

const replaceVersion = (str: string) => str.replaceAll(version, "XX.XX.XX");

expect(replaceVersion(output.combinedOutput)).toMatchInlineSnapshot(`
lerna notice cli v999.9.9-e2e.0
Found 2 packages to publish:
- test-1 => XX.XX.XX
- test-2 => XX.XX.XX
lerna info auto-confirmed
lerna info publish Publishing packages to npm...
lerna notice Skipping all user and access validation due to third-party registry
lerna notice Make sure you're authenticated properly ¯\\_(ツ)_/¯
lerna WARN ENOLICENSE Packages test-1 and test-2 are missing a license.
lerna WARN ENOLICENSE One way to fix this is to add a LICENSE.md file to the root of this repository.
lerna WARN ENOLICENSE See https://choosealicense.com for additional guidance.
lerna WARN publish Package is already published: test-1@XX.XX.XX
lerna success published test-2 XX.XX.XX
lerna notice
lerna notice 📦 test-2@XX.XX.XX
lerna notice === Tarball Contents ===
lerna notice 92B lib/test-2.js
lerna notice XXXB package.json
lerna notice 110B README.md
lerna notice === Tarball Details ===
lerna notice name: test-2
lerna notice version: XX.XX.XX
lerna notice filename: test-2-XX.XX.XX.tgz
lerna notice package size: XXXB
lerna notice unpacked size: XXX.XXX kb
lerna notice shasum: {FULL_COMMIT_SHA}
lerna notice integrity: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
lerna notice total files: 3
lerna notice
Successfully published:
- test-2@XX.XX.XX
lerna success published 1 package
`);

expect(replaceVersion(unpublishOutput1.combinedOutput)).toMatchInlineSnapshot(`
npm WARN using --force Recommended protections disabled.
- test-1@XX.XX.XX
`);

expect(replaceVersion(unpublishOutput2.combinedOutput)).toMatchInlineSnapshot(`
npm WARN using --force Recommended protections disabled.
- test-2@XX.XX.XX
`);
});
});
});
2 changes: 2 additions & 0 deletions libs/commands/publish/README.md
Expand Up @@ -26,6 +26,8 @@ During all publish operations, appropriate [lifecycle scripts](#lifecycle-script

Check out [Per-Package Configuration](#per-package-configuration) for more details about publishing scoped packages, custom registries, and custom dist-tags.

> Note: See the [FAQ](https://lerna.js.org/docs/faq#how-do-i-retry-publishing-if-publish-fails) for information on how to recover from a failed publish.
## Positionals

### bump `from-git`
Expand Down
47 changes: 38 additions & 9 deletions libs/commands/publish/src/index.ts
Expand Up @@ -270,7 +270,13 @@ class PublishCommand extends Command {
}

return chain.then(() => {
const count = this.packagesToPublish.length;
const count = this.publishedPackages.length;
const publishedPackagesSorted = this.publishedPackages.sort((a, b) => a.name.localeCompare(b.name));

if (!count) {
this.logger.success("All packages have already been published.");
return;
}

output("Successfully published:");

Expand All @@ -279,7 +285,7 @@ class PublishCommand extends Command {
const filePath = this.options.summaryFile
? `${this.options.summaryFile}/lerna-publish-summary.json`
: "./lerna-publish-summary.json";
const jsonObject = this.packagesToPublish.map((pkg) => {
const jsonObject = publishedPackagesSorted.map((pkg) => {
return {
packageName: pkg.name,
version: pkg.version,
Expand All @@ -293,7 +299,7 @@ class PublishCommand extends Command {
output("Failed to create the summary report", error);
}
} else {
const message = this.packagesToPublish.map((pkg) => ` - ${pkg.name}@${pkg.version}`);
const message = publishedPackagesSorted.map((pkg) => ` - ${pkg.name}@${pkg.version}`);
output(message.join(os.EOL));
}

Expand Down Expand Up @@ -805,6 +811,8 @@ class PublishCommand extends Command {
}

publishPacked() {
this.publishedPackages = [];

const tracker = this.logger.newItem("publish");

tracker.addWork(this.packagesToPublish.length);
Expand All @@ -829,14 +837,35 @@ class PublishCommand extends Command {
const tag = !this.options.tempTag && preDistTag ? preDistTag : opts.tag;
const pkgOpts = Object.assign({}, opts, { tag });

return pulseTillDone(npmPublish(pkg, pkg.packed.tarFilePath, pkgOpts, this.otpCache)).then(() => {
tracker.success("published", pkg.name, pkg.version);
tracker.completeWork(1);
return pulseTillDone(npmPublish(pkg, pkg.packed.tarFilePath, pkgOpts, this.otpCache))
.then(() => {
this.publishedPackages.push(pkg);

tracker.success("published", pkg.name, pkg.version);
tracker.completeWork(1);

logPacked(pkg.packed);

return pkg;
})
.catch((err) => {
if (err.code === "EPUBLISHCONFLICT") {
tracker.warn("publish", `Package is already published: ${pkg.name}@${pkg.version}`);
tracker.completeWork(1);

return pkg;
}

this.logger.silly("", err);
this.logger.error(err.code, (err.body && err.body.error) || err.message);

logPacked(pkg.packed);
// avoid dumping logs, this isn't a lerna problem
err.name = "ValidationError";
// ensure process exits non-zero
process.exitCode = "errno" in err ? err.errno : 1;

return pkg;
});
throw err;
});
},

this.options.requireScripts && ((pkg) => this.execScript(pkg, "postpublish")),
Expand Down
40 changes: 0 additions & 40 deletions libs/core/src/lib/npm-publish.test.ts
Expand Up @@ -225,44 +225,4 @@ describe("npm-publish", () => {
expect(runLifecycle).toHaveBeenCalledWith(pkg, "publish", options);
expect(runLifecycle).toHaveBeenLastCalledWith(pkg, "postpublish", options);
});

it("catches libnpm errors", async () => {
publish.mockImplementationOnce(() => {
const err = new Error("whoopsy") as any;
err.code = "E401";
err.body = {
error: "doodle",
};
return Promise.reject(err);
});

const log = {
verbose: jest.fn(),
silly: jest.fn(),
error: jest.fn(),
};
const opts = { log };

await expect(npmPublish(pkg, tarFilePath, opts as any)).rejects.toThrow(
expect.objectContaining({
message: "whoopsy",
name: "ValidationError",
})
);

expect(log.error).toHaveBeenLastCalledWith("E401", "doodle");
expect(process.exitCode).toBe(1);

publish.mockImplementationOnce(() => {
const err = new Error("lolwut") as any;
err.code = "E404";
err.errno = 9001;
return Promise.reject(err);
});

await expect(npmPublish(pkg, tarFilePath, opts as any)).rejects.toThrow("lolwut");

expect(log.error).toHaveBeenLastCalledWith("E404", "lolwut");
expect(process.exitCode).toBe(9001);
});
});
14 changes: 1 addition & 13 deletions libs/core/src/lib/npm-publish.ts
Expand Up @@ -102,19 +102,7 @@ export function npmPublish(
// TODO: refactor based on TS feedback
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return otplease((innerOpts) => publish(manifest, tarData, innerOpts), opts, otpCache).catch((err) => {
opts.log.silly("", err);
opts.log.error(err.code, (err.body && err.body.error) || err.message);

// avoid dumping logs, this isn't a lerna problem
err.name = "ValidationError";

// ensure process exits non-zero
process.exitCode = "errno" in err ? err.errno : 1;

// re-throw to break chain upstream
throw err;
});
return otplease((innerOpts) => publish(manifest, tarData, innerOpts), opts, otpCache);
});
}

Expand Down
4 changes: 2 additions & 2 deletions libs/e2e-utils/src/lib/fixture.ts
Expand Up @@ -176,12 +176,12 @@ export class Fixture {
*/
async exec(
command: string,
opts: Exclude<RunCommandOptions, "cwd"> = {
opts: RunCommandOptions = {
silenceError: false,
env: undefined,
}
): Promise<RunCommandResult> {
return this.execImpl(command, { ...opts, cwd: this.fixtureWorkspacePath });
return this.execImpl(command, { ...opts, cwd: opts.cwd ?? this.fixtureWorkspacePath });
}

/**
Expand Down
6 changes: 6 additions & 0 deletions website/docs/faq.md
Expand Up @@ -39,6 +39,12 @@ If the `lerna.json` has not yet been updated, simply try `lerna publish` again.

If it has been updated, you can force re-publish. `lerna publish --force-publish $(ls packages/)`

### Recovering from a network error

In the case that some packages were successfully published and others were not, `lerna publish` may have left the repository in an inconsistent state with some changed files. To recover from this, reset any extraneous local changes from the failed run to get back to a clean working tree. Then, retry the same `lerna publish` command. Lerna will attempt to publish all of the packages again, but will recognize those that have already been published and skip over them with a warning.

If you used the `lerna publish` command without positional arguments to select a new version for the packages, then you can run `lerna publish from-git` to retry publishing that same already-tagged version instead of having to bump the version again while retrying.

## The bootstrap process is really slow, what can I do?

Projects having many packages inside them could take a very long time to bootstrap.
Expand Down

0 comments on commit f03ee3e

Please sign in to comment.