Skip to content

Commit 3f1b4fc

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 a0ad67a commit 3f1b4fc

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
@@ -3993,10 +3993,28 @@ Caused by:
39933993
// Publishing the whole workspace now will fail, as `a` is already published.
39943994
p.cargo("publish")
39953995
.replace_crates_io(registry.index_url())
3996-
.with_status(101)
39973996
.with_stderr_data(str![[r#"
39983997
[UPDATING] crates.io index
3999-
[ERROR] crate [email protected] already exists on crates.io index
3998+
[WARNING] crate [email protected] already exists on crates.io index
3999+
[PACKAGING] a v0.0.1 ([ROOT]/foo/a)
4000+
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
4001+
[PACKAGING] b v0.0.1 ([ROOT]/foo/b)
4002+
[UPDATING] crates.io index
4003+
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
4004+
[VERIFYING] a v0.0.1 ([ROOT]/foo/a)
4005+
[COMPILING] a v0.0.1 ([ROOT]/foo/target/package/a-0.0.1)
4006+
[FINISHED] `dev` profile [unoptimized + debuginfo] target(s) in [ELAPSED]s
4007+
[VERIFYING] b v0.0.1 ([ROOT]/foo/b)
4008+
[UNPACKING] a v0.0.1 (registry `[ROOT]/foo/target/package/tmp-registry`)
4009+
[COMPILING] a v0.0.1
4010+
[COMPILING] b v0.0.1 ([ROOT]/foo/target/package/b-0.0.1)
4011+
[FINISHED] `dev` profile [unoptimized + debuginfo] target(s) in [ELAPSED]s
4012+
[WARNING] skipping upload for crate [email protected]: already exists on crates.io index
4013+
[UPLOADING] b v0.0.1 ([ROOT]/foo/b)
4014+
[UPLOADED] b v0.0.1 to registry `crates-io`
4015+
[NOTE] waiting for b v0.0.1 to be available at registry `crates-io`
4016+
[HELP] you may press ctrl-c to skip waiting; the crate should be available shortly
4017+
[PUBLISHED] b v0.0.1 at registry `crates-io`
40004018
40014019
"#]])
40024020
.run();
@@ -4430,21 +4448,33 @@ fn all_published_packages() {
44304448
// Publishing all members again works
44314449
p.cargo("publish --workspace --no-verify")
44324450
.replace_crates_io(registry.index_url())
4433-
.with_status(101)
44344451
.with_stderr_data(str![[r#"
44354452
[UPDATING] crates.io index
4436-
[ERROR] crate [email protected] already exists on crates.io index
4453+
[WARNING] crate [email protected] already exists on crates.io index
4454+
[WARNING] crate [email protected] already exists on crates.io index
4455+
[PACKAGING] bar v0.0.0 ([ROOT]/foo/bar)
4456+
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
4457+
[PACKAGING] foo v0.0.0 ([ROOT]/foo/foo)
4458+
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
4459+
[WARNING] skipping upload for crate [email protected]: already exists on crates.io index
4460+
[WARNING] skipping upload for crate [email protected]: already exists on crates.io index
44374461
44384462
"#]])
44394463
.run();
44404464

44414465
// Without `--workspace` works as it is a virtual workspace
44424466
p.cargo("publish --no-verify")
44434467
.replace_crates_io(registry.index_url())
4444-
.with_status(101)
44454468
.with_stderr_data(str![[r#"
44464469
[UPDATING] crates.io index
4447-
[ERROR] crate [email protected] already exists on crates.io index
4470+
[WARNING] crate [email protected] already exists on crates.io index
4471+
[WARNING] crate [email protected] already exists on crates.io index
4472+
[PACKAGING] bar v0.0.0 ([ROOT]/foo/bar)
4473+
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
4474+
[PACKAGING] foo v0.0.0 ([ROOT]/foo/foo)
4475+
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
4476+
[WARNING] skipping upload for crate [email protected]: already exists on crates.io index
4477+
[WARNING] skipping upload for crate [email protected]: already exists on crates.io index
44484478
44494479
"#]])
44504480
.run();
@@ -4456,7 +4486,14 @@ fn all_published_packages() {
44564486
.with_status(101)
44574487
.with_stderr_data(str![[r#"
44584488
[UPDATING] crates.io index
4459-
[ERROR] crate [email protected] already exists on crates.io index
4489+
[WARNING] crate [email protected] already exists on crates.io index
4490+
[WARNING] crate [email protected] already exists on crates.io index
4491+
[PACKAGING] bar v0.0.0 ([ROOT]/foo/bar)
4492+
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
4493+
[PACKAGING] foo v0.0.0 ([ROOT]/foo/foo)
4494+
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
4495+
[ERROR] crate [email protected] already exists on crates.io index but tarball checksum mismatched
4496+
perhaps local files have changed but forgot to bump the version?
44604497
44614498
"#]])
44624499
.run();

0 commit comments

Comments
 (0)