Skip to content

Commit 45d9430

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 684ab13 commit 45d9430

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
@@ -29,6 +29,7 @@ use crate::core::Package;
2929
use crate::core::PackageId;
3030
use crate::core::PackageIdSpecQuery;
3131
use crate::core::SourceId;
32+
use crate::core::Summary;
3233
use crate::core::Workspace;
3334
use crate::core::dependency::DepKind;
3435
use crate::core::manifest::ManifestMetadata;
@@ -86,15 +87,17 @@ pub fn publish(ws: &Workspace<'_>, opts: &PublishOpts<'_>) -> CargoResult<()> {
8687
.into_iter()
8788
.partition(|(pkg, _)| pkg.publish() == &Some(vec![]));
8889
// If `--workspace` is passed,
89-
// the intent is more like "publish all publisable packages in this workspace",
90-
// so skip `publish=false` packages.
91-
let allow_unpublishable = match &opts.to_publish {
90+
// the intent is more like "publish all publisable packages in this workspace".
91+
// Hence,
92+
// * skip `publish=false` packages
93+
// * skip already published packages
94+
let is_workspace_publish = match &opts.to_publish {
9295
Packages::Default => ws.is_virtual(),
9396
Packages::All(_) => true,
9497
Packages::OptOut(_) => true,
9598
Packages::Packages(_) => false,
9699
};
97-
if !unpublishable.is_empty() && !allow_unpublishable {
100+
if !unpublishable.is_empty() && !is_workspace_publish {
98101
bail!(
99102
"{} cannot be published.\n\
100103
`package.publish` must be set to `true` or a non-empty list in Cargo.toml to publish.",
@@ -106,7 +109,7 @@ pub fn publish(ws: &Workspace<'_>, opts: &PublishOpts<'_>) -> CargoResult<()> {
106109
}
107110

108111
if pkgs.is_empty() {
109-
if allow_unpublishable {
112+
if is_workspace_publish {
110113
let n = unpublishable.len();
111114
let plural = if n == 1 { "" } else { "s" };
112115
ws.gctx().shell().print_report(
@@ -159,13 +162,30 @@ pub fn publish(ws: &Workspace<'_>, opts: &PublishOpts<'_>) -> CargoResult<()> {
159162
Some(Operation::Read).filter(|_| !opts.dry_run),
160163
)?;
161164

165+
// `maybe_published` tracks package versions that already exist in the registry,
166+
// meaning they might have been published before.
167+
// Later, we verify the tarball checksum to see
168+
// if the local package matches the registry.
169+
// This helps catch cases where the local version
170+
// wasn’t bumped but files changed.
171+
let mut maybe_published = HashMap::new();
172+
162173
{
163174
let _lock = opts
164175
.gctx
165176
.acquire_package_cache_lock(CacheLockMode::DownloadExclusive)?;
166177

167178
for (pkg, _) in &pkgs {
168-
verify_unpublished(pkg, &mut source, &source_ids, opts.dry_run, opts.gctx)?;
179+
if let Some(summary) = verify_unpublished(
180+
pkg,
181+
&mut source,
182+
&source_ids,
183+
opts.dry_run,
184+
is_workspace_publish,
185+
opts.gctx,
186+
)? {
187+
maybe_published.insert(pkg.package_id(), summary);
188+
}
169189
verify_dependencies(pkg, &registry, source_ids.original).map_err(|err| {
170190
ManifestError::new(
171191
err.context(format!(
@@ -218,15 +238,38 @@ pub fn publish(ws: &Workspace<'_>, opts: &PublishOpts<'_>) -> CargoResult<()> {
218238
let mut ready = plan.take_ready();
219239
while let Some(pkg_id) = ready.pop_first() {
220240
let (pkg, (_features, tarball)) = &pkg_dep_graph.packages[&pkg_id];
221-
opts.gctx.shell().status("Uploading", pkg.package_id())?;
222-
223-
if !opts.dry_run {
224-
let ver = pkg.version().to_string();
225241

242+
if opts.dry_run {
243+
opts.gctx.shell().status("Uploading", pkg.package_id())?;
244+
} else {
226245
tarball.file().seek(SeekFrom::Start(0))?;
227246
let hash = cargo_util::Sha256::new()
228247
.update_file(tarball.file())?
229248
.finish_hex();
249+
250+
if let Some(summary) = maybe_published.get(&pkg.package_id()) {
251+
if summary.checksum() == Some(hash.as_str()) {
252+
opts.gctx.shell().warn(format_args!(
253+
"skipping upload for crate {}@{}: already exists on {}",
254+
pkg.name(),
255+
pkg.version(),
256+
source.describe()
257+
))?;
258+
plan.mark_confirmed([pkg.package_id()]);
259+
continue;
260+
}
261+
bail!(
262+
"crate {}@{} already exists on {} but tarball checksum mismatched\n\
263+
perhaps local files have changed but forgot to bump the version?",
264+
pkg.name(),
265+
pkg.version(),
266+
source.describe()
267+
);
268+
}
269+
270+
opts.gctx.shell().status("Uploading", pkg.package_id())?;
271+
272+
let ver = pkg.version().to_string();
230273
let operation = Operation::Publish {
231274
name: pkg.name().as_str(),
232275
vers: &ver,
@@ -278,6 +321,12 @@ pub fn publish(ws: &Workspace<'_>, opts: &PublishOpts<'_>) -> CargoResult<()> {
278321
}
279322
}
280323

324+
if to_confirm.is_empty() {
325+
// nothing to confirm because some are already uploaded before
326+
// this cargo invocation.
327+
continue;
328+
}
329+
281330
let confirmed = if opts.dry_run {
282331
to_confirm.clone()
283332
} else {
@@ -450,13 +499,18 @@ fn poll_one_package(
450499
Ok(!summaries.is_empty())
451500
}
452501

502+
/// Checks if a package is already published.
503+
///
504+
/// Returns a [`Summary`] for computing the tarball checksum
505+
/// to compare with the registry index later, if needed.
453506
fn verify_unpublished(
454507
pkg: &Package,
455508
source: &mut RegistrySource<'_>,
456509
source_ids: &RegistrySourceIds,
457510
dry_run: bool,
511+
skip_already_publish: bool,
458512
gctx: &GlobalContext,
459-
) -> CargoResult<()> {
513+
) -> CargoResult<Option<Summary>> {
460514
let query = Dependency::parse(
461515
pkg.name(),
462516
Some(&pkg.version().to_exact_req().to_string()),
@@ -470,28 +524,36 @@ fn verify_unpublished(
470524
std::task::Poll::Pending => source.block_until_ready()?,
471525
}
472526
};
473-
if !duplicate_query.is_empty() {
474-
// Move the registry error earlier in the publish process.
475-
// Since dry-run wouldn't talk to the registry to get the error, we downgrade it to a
476-
// warning.
477-
if dry_run {
478-
gctx.shell().warn(format!(
479-
"crate {}@{} already exists on {}",
480-
pkg.name(),
481-
pkg.version(),
482-
source.describe()
483-
))?;
484-
} else {
485-
bail!(
486-
"crate {}@{} already exists on {}",
487-
pkg.name(),
488-
pkg.version(),
489-
source.describe()
490-
);
491-
}
527+
if duplicate_query.is_empty() {
528+
return Ok(None);
492529
}
493530

494-
Ok(())
531+
// Move the registry error earlier in the publish process.
532+
// Since dry-run wouldn't talk to the registry to get the error,
533+
// we downgrade it to a warning.
534+
if skip_already_publish || dry_run {
535+
gctx.shell().warn(format!(
536+
"crate {}@{} already exists on {}",
537+
pkg.name(),
538+
pkg.version(),
539+
source.describe()
540+
))?;
541+
} else {
542+
bail!(
543+
"crate {}@{} already exists on {}",
544+
pkg.name(),
545+
pkg.version(),
546+
source.describe()
547+
);
548+
}
549+
550+
assert_eq!(
551+
duplicate_query.len(),
552+
1,
553+
"registry must not have duplicat versions",
554+
);
555+
let summary = duplicate_query.into_iter().next().unwrap().into_summary();
556+
Ok(skip_already_publish.then_some(summary))
495557
}
496558

497559
fn verify_dependencies(

tests/testsuite/publish.rs

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3995,10 +3995,28 @@ Caused by:
39953995
// Publishing the whole workspace now will fail, as `a` is already published.
39963996
p.cargo("publish")
39973997
.replace_crates_io(registry.index_url())
3998-
.with_status(101)
39993998
.with_stderr_data(str![[r#"
40003999
[UPDATING] crates.io index
4001-
[ERROR] crate [email protected] already exists on crates.io index
4000+
[WARNING] crate [email protected] already exists on crates.io index
4001+
[PACKAGING] a v0.0.1 ([ROOT]/foo/a)
4002+
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
4003+
[PACKAGING] b v0.0.1 ([ROOT]/foo/b)
4004+
[UPDATING] crates.io index
4005+
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
4006+
[VERIFYING] a v0.0.1 ([ROOT]/foo/a)
4007+
[COMPILING] a v0.0.1 ([ROOT]/foo/target/package/a-0.0.1)
4008+
[FINISHED] `dev` profile [unoptimized + debuginfo] target(s) in [ELAPSED]s
4009+
[VERIFYING] b v0.0.1 ([ROOT]/foo/b)
4010+
[UNPACKING] a v0.0.1 (registry `[ROOT]/foo/target/package/tmp-registry`)
4011+
[COMPILING] a v0.0.1
4012+
[COMPILING] b v0.0.1 ([ROOT]/foo/target/package/b-0.0.1)
4013+
[FINISHED] `dev` profile [unoptimized + debuginfo] target(s) in [ELAPSED]s
4014+
[WARNING] skipping upload for crate [email protected]: already exists on crates.io index
4015+
[UPLOADING] b v0.0.1 ([ROOT]/foo/b)
4016+
[UPLOADED] b v0.0.1 to registry `crates-io`
4017+
[NOTE] waiting for b v0.0.1 to be available at registry `crates-io`
4018+
[HELP] you may press ctrl-c to skip waiting; the crate should be available shortly
4019+
[PUBLISHED] b v0.0.1 at registry `crates-io`
40024020
40034021
"#]])
40044022
.run();
@@ -4433,21 +4451,33 @@ fn all_published_packages() {
44334451
// Publishing all members again works
44344452
p.cargo("publish --workspace --no-verify")
44354453
.replace_crates_io(registry.index_url())
4436-
.with_status(101)
44374454
.with_stderr_data(str![[r#"
44384455
[UPDATING] crates.io index
4439-
[ERROR] crate [email protected] already exists on crates.io index
4456+
[WARNING] crate [email protected] already exists on crates.io index
4457+
[WARNING] crate [email protected] already exists on crates.io index
4458+
[PACKAGING] bar v0.0.0 ([ROOT]/foo/bar)
4459+
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
4460+
[PACKAGING] foo v0.0.0 ([ROOT]/foo/foo)
4461+
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
4462+
[WARNING] skipping upload for crate [email protected]: already exists on crates.io index
4463+
[WARNING] skipping upload for crate [email protected]: already exists on crates.io index
44404464
44414465
"#]])
44424466
.run();
44434467

44444468
// Without `--workspace` works as it is a virtual workspace
44454469
p.cargo("publish --no-verify")
44464470
.replace_crates_io(registry.index_url())
4447-
.with_status(101)
44484471
.with_stderr_data(str![[r#"
44494472
[UPDATING] crates.io index
4450-
[ERROR] crate [email protected] already exists on crates.io index
4473+
[WARNING] crate [email protected] already exists on crates.io index
4474+
[WARNING] crate [email protected] already exists on crates.io index
4475+
[PACKAGING] bar v0.0.0 ([ROOT]/foo/bar)
4476+
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
4477+
[PACKAGING] foo v0.0.0 ([ROOT]/foo/foo)
4478+
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
4479+
[WARNING] skipping upload for crate [email protected]: already exists on crates.io index
4480+
[WARNING] skipping upload for crate [email protected]: already exists on crates.io index
44514481
44524482
"#]])
44534483
.run();
@@ -4459,7 +4489,14 @@ fn all_published_packages() {
44594489
.with_status(101)
44604490
.with_stderr_data(str![[r#"
44614491
[UPDATING] crates.io index
4462-
[ERROR] crate [email protected] already exists on crates.io index
4492+
[WARNING] crate [email protected] already exists on crates.io index
4493+
[WARNING] crate [email protected] already exists on crates.io index
4494+
[PACKAGING] bar v0.0.0 ([ROOT]/foo/bar)
4495+
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
4496+
[PACKAGING] foo v0.0.0 ([ROOT]/foo/foo)
4497+
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
4498+
[ERROR] crate [email protected] already exists on crates.io index but tarball checksum mismatched
4499+
perhaps local files have changed but forgot to bump the version?
44634500
44644501
"#]])
44654502
.run();

0 commit comments

Comments
 (0)