Skip to content

Commit e3d7ab5

Browse files
committed
feat(publish): idempotent workspace publish
Before this, `cargo publish --workspace` would fail if any member packages were already published. After this, it skips already published packages and continues with the rest. To make sure the local package is really published, we verify the checksum of the newly packed `.crate` tarball against the checksum from the registry index. This helps catch cases where the package contents changed but the version wasn’t bumped, which would otherwise be treated as already published.
1 parent 8f9ac58 commit e3d7ab5

File tree

2 files changed

+137
-38
lines changed

2 files changed

+137
-38
lines changed

src/cargo/ops/registry/publish.rs

Lines changed: 93 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ use crate::core::Package;
2828
use crate::core::PackageId;
2929
use crate::core::PackageIdSpecQuery;
3030
use crate::core::SourceId;
31+
use crate::core::Summary;
3132
use crate::core::Workspace;
3233
use crate::core::dependency::DepKind;
3334
use crate::core::manifest::ManifestMetadata;
@@ -85,15 +86,17 @@ pub fn publish(ws: &Workspace<'_>, opts: &PublishOpts<'_>) -> CargoResult<()> {
8586
.into_iter()
8687
.partition(|(pkg, _)| pkg.publish() == &Some(vec![]));
8788
// If `--workspace` is passed,
88-
// the intent is more like "publish all publisable packages in this workspace",
89-
// so skip `publish=false` packages.
90-
let allow_unpublishable = match &opts.to_publish {
89+
// the intent is more like "publish all publisable packages in this workspace".
90+
// Hence,
91+
// * skip `publish=false` packages
92+
// * skip already published packages
93+
let is_workspace_publish = match &opts.to_publish {
9194
Packages::Default => ws.is_virtual(),
9295
Packages::All(_) => true,
9396
Packages::OptOut(_) => true,
9497
Packages::Packages(_) => false,
9598
};
96-
if !unpublishable.is_empty() && !allow_unpublishable {
99+
if !unpublishable.is_empty() && !is_workspace_publish {
97100
bail!(
98101
"{} cannot be published.\n\
99102
`package.publish` must be set to `true` or a non-empty list in Cargo.toml to publish.",
@@ -105,7 +108,7 @@ pub fn publish(ws: &Workspace<'_>, opts: &PublishOpts<'_>) -> CargoResult<()> {
105108
}
106109

107110
if pkgs.is_empty() {
108-
if allow_unpublishable {
111+
if is_workspace_publish {
109112
let n = unpublishable.len();
110113
let plural = if n == 1 { "" } else { "s" };
111114
ws.gctx().shell().warn(format_args!(
@@ -154,13 +157,30 @@ pub fn publish(ws: &Workspace<'_>, opts: &PublishOpts<'_>) -> CargoResult<()> {
154157
Some(Operation::Read).filter(|_| !opts.dry_run),
155158
)?;
156159

160+
// `maybe_published` tracks package versions that already exist in the registry,
161+
// meaning they might have been published before.
162+
// Later, we verify the tarball checksum to see
163+
// if the local package matches the registry.
164+
// This helps catch cases where the local version
165+
// wasn’t bumped but files changed.
166+
let mut maybe_published = HashMap::new();
167+
157168
{
158169
let _lock = opts
159170
.gctx
160171
.acquire_package_cache_lock(CacheLockMode::DownloadExclusive)?;
161172

162173
for (pkg, _) in &pkgs {
163-
verify_unpublished(pkg, &mut source, &source_ids, opts.dry_run, opts.gctx)?;
174+
if let Some(summary) = verify_unpublished(
175+
pkg,
176+
&mut source,
177+
&source_ids,
178+
opts.dry_run,
179+
is_workspace_publish,
180+
opts.gctx,
181+
)? {
182+
maybe_published.insert(pkg.package_id(), summary);
183+
}
164184
verify_dependencies(pkg, &registry, source_ids.original).map_err(|err| {
165185
ManifestError::new(
166186
err.context(format!(
@@ -213,15 +233,38 @@ pub fn publish(ws: &Workspace<'_>, opts: &PublishOpts<'_>) -> CargoResult<()> {
213233
let mut ready = plan.take_ready();
214234
while let Some(pkg_id) = ready.pop_first() {
215235
let (pkg, (_features, tarball)) = &pkg_dep_graph.packages[&pkg_id];
216-
opts.gctx.shell().status("Uploading", pkg.package_id())?;
217-
218-
if !opts.dry_run {
219-
let ver = pkg.version().to_string();
220236

237+
if opts.dry_run {
238+
opts.gctx.shell().status("Uploading", pkg.package_id())?;
239+
} else {
221240
tarball.file().seek(SeekFrom::Start(0))?;
222241
let hash = cargo_util::Sha256::new()
223242
.update_file(tarball.file())?
224243
.finish_hex();
244+
245+
if let Some(summary) = maybe_published.get(&pkg.package_id()) {
246+
if summary.checksum() == Some(hash.as_str()) {
247+
opts.gctx.shell().warn(format_args!(
248+
"skipping upload for crate {}@{}: already exists on {}",
249+
pkg.name(),
250+
pkg.version(),
251+
source.describe()
252+
))?;
253+
plan.mark_confirmed([pkg.package_id()]);
254+
continue;
255+
}
256+
bail!(
257+
"crate {}@{} already exists on {} but tarball checksum mismatched\n\
258+
perhaps local files have changed but forgot to bump the version?",
259+
pkg.name(),
260+
pkg.version(),
261+
source.describe()
262+
);
263+
}
264+
265+
opts.gctx.shell().status("Uploading", pkg.package_id())?;
266+
267+
let ver = pkg.version().to_string();
225268
let operation = Operation::Publish {
226269
name: pkg.name().as_str(),
227270
vers: &ver,
@@ -273,6 +316,12 @@ pub fn publish(ws: &Workspace<'_>, opts: &PublishOpts<'_>) -> CargoResult<()> {
273316
}
274317
}
275318

319+
if to_confirm.is_empty() {
320+
// nothing to confirm because some are already uploaded before
321+
// this cargo invocation.
322+
continue;
323+
}
324+
276325
let confirmed = if opts.dry_run {
277326
to_confirm.clone()
278327
} else {
@@ -440,13 +489,18 @@ fn poll_one_package(
440489
Ok(!summaries.is_empty())
441490
}
442491

492+
/// Checks if a package is already published.
493+
///
494+
/// Returns a [`Summary`] for computing the tarball checksum
495+
/// to compare with the registry index later, if needed.
443496
fn verify_unpublished(
444497
pkg: &Package,
445498
source: &mut RegistrySource<'_>,
446499
source_ids: &RegistrySourceIds,
447500
dry_run: bool,
501+
skip_already_publish: bool,
448502
gctx: &GlobalContext,
449-
) -> CargoResult<()> {
503+
) -> CargoResult<Option<Summary>> {
450504
let query = Dependency::parse(
451505
pkg.name(),
452506
Some(&pkg.version().to_exact_req().to_string()),
@@ -460,28 +514,36 @@ fn verify_unpublished(
460514
std::task::Poll::Pending => source.block_until_ready()?,
461515
}
462516
};
463-
if !duplicate_query.is_empty() {
464-
// Move the registry error earlier in the publish process.
465-
// Since dry-run wouldn't talk to the registry to get the error, we downgrade it to a
466-
// warning.
467-
if dry_run {
468-
gctx.shell().warn(format!(
469-
"crate {}@{} already exists on {}",
470-
pkg.name(),
471-
pkg.version(),
472-
source.describe()
473-
))?;
474-
} else {
475-
bail!(
476-
"crate {}@{} already exists on {}",
477-
pkg.name(),
478-
pkg.version(),
479-
source.describe()
480-
);
481-
}
517+
if duplicate_query.is_empty() {
518+
return Ok(None);
482519
}
483520

484-
Ok(())
521+
// Move the registry error earlier in the publish process.
522+
// Since dry-run wouldn't talk to the registry to get the error,
523+
// we downgrade it to a warning.
524+
if skip_already_publish || dry_run {
525+
gctx.shell().warn(format!(
526+
"crate {}@{} already exists on {}",
527+
pkg.name(),
528+
pkg.version(),
529+
source.describe()
530+
))?;
531+
} else {
532+
bail!(
533+
"crate {}@{} already exists on {}",
534+
pkg.name(),
535+
pkg.version(),
536+
source.describe()
537+
);
538+
}
539+
540+
assert_eq!(
541+
duplicate_query.len(),
542+
1,
543+
"registry must not have duplicat versions",
544+
);
545+
let summary = duplicate_query.into_iter().next().unwrap().into_summary();
546+
Ok(skip_already_publish.then_some(summary))
485547
}
486548

487549
fn verify_dependencies(

tests/testsuite/publish.rs

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3954,10 +3954,28 @@ Caused by:
39543954
// Publishing the whole workspace now will fail, as `a` is already published.
39553955
p.cargo("publish")
39563956
.replace_crates_io(registry.index_url())
3957-
.with_status(101)
39583957
.with_stderr_data(str![[r#"
39593958
[UPDATING] crates.io index
3960-
[ERROR] crate [email protected] already exists on crates.io index
3959+
[WARNING] crate [email protected] already exists on crates.io index
3960+
[PACKAGING] a v0.0.1 ([ROOT]/foo/a)
3961+
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
3962+
[PACKAGING] b v0.0.1 ([ROOT]/foo/b)
3963+
[UPDATING] crates.io index
3964+
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
3965+
[VERIFYING] a v0.0.1 ([ROOT]/foo/a)
3966+
[COMPILING] a v0.0.1 ([ROOT]/foo/target/package/a-0.0.1)
3967+
[FINISHED] `dev` profile [unoptimized + debuginfo] target(s) in [ELAPSED]s
3968+
[VERIFYING] b v0.0.1 ([ROOT]/foo/b)
3969+
[UNPACKING] a v0.0.1 (registry `[ROOT]/foo/target/package/tmp-registry`)
3970+
[COMPILING] a v0.0.1
3971+
[COMPILING] b v0.0.1 ([ROOT]/foo/target/package/b-0.0.1)
3972+
[FINISHED] `dev` profile [unoptimized + debuginfo] target(s) in [ELAPSED]s
3973+
[WARNING] skipping upload for crate [email protected]: already exists on crates.io index
3974+
[UPLOADING] b v0.0.1 ([ROOT]/foo/b)
3975+
[UPLOADED] b v0.0.1 to registry `crates-io`
3976+
[NOTE] waiting for b v0.0.1 to be available at registry `crates-io`
3977+
[HELP] you may press ctrl-c to skip waiting; the crate should be available shortly
3978+
[PUBLISHED] b v0.0.1 at registry `crates-io`
39613979
39623980
"#]])
39633981
.run();
@@ -4391,21 +4409,33 @@ fn all_published_packages() {
43914409
// Publishing all members again works
43924410
p.cargo("publish --workspace --no-verify")
43934411
.replace_crates_io(registry.index_url())
4394-
.with_status(101)
43954412
.with_stderr_data(str![[r#"
43964413
[UPDATING] crates.io index
4397-
[ERROR] crate [email protected] already exists on crates.io index
4414+
[WARNING] crate [email protected] already exists on crates.io index
4415+
[WARNING] crate [email protected] already exists on crates.io index
4416+
[PACKAGING] bar v0.0.0 ([ROOT]/foo/bar)
4417+
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
4418+
[PACKAGING] foo v0.0.0 ([ROOT]/foo/foo)
4419+
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
4420+
[WARNING] skipping upload for crate [email protected]: already exists on crates.io index
4421+
[WARNING] skipping upload for crate [email protected]: already exists on crates.io index
43984422
43994423
"#]])
44004424
.run();
44014425

44024426
// Without `--workspace` works as it is a virtual workspace
44034427
p.cargo("publish --no-verify")
44044428
.replace_crates_io(registry.index_url())
4405-
.with_status(101)
44064429
.with_stderr_data(str![[r#"
44074430
[UPDATING] crates.io index
4408-
[ERROR] crate [email protected] already exists on crates.io index
4431+
[WARNING] crate [email protected] already exists on crates.io index
4432+
[WARNING] crate [email protected] already exists on crates.io index
4433+
[PACKAGING] bar v0.0.0 ([ROOT]/foo/bar)
4434+
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
4435+
[PACKAGING] foo v0.0.0 ([ROOT]/foo/foo)
4436+
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
4437+
[WARNING] skipping upload for crate [email protected]: already exists on crates.io index
4438+
[WARNING] skipping upload for crate [email protected]: already exists on crates.io index
44094439
44104440
"#]])
44114441
.run();
@@ -4417,7 +4447,14 @@ fn all_published_packages() {
44174447
.with_status(101)
44184448
.with_stderr_data(str![[r#"
44194449
[UPDATING] crates.io index
4420-
[ERROR] crate [email protected] already exists on crates.io index
4450+
[WARNING] crate [email protected] already exists on crates.io index
4451+
[WARNING] crate [email protected] already exists on crates.io index
4452+
[PACKAGING] bar v0.0.0 ([ROOT]/foo/bar)
4453+
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
4454+
[PACKAGING] foo v0.0.0 ([ROOT]/foo/foo)
4455+
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
4456+
[ERROR] crate [email protected] already exists on crates.io index but tarball checksum mismatched
4457+
perhaps local files have changed but forgot to bump the version?
44214458
44224459
"#]])
44234460
.run();

0 commit comments

Comments
 (0)