1use std::io::IsTerminal;
27use std::path::{Path, PathBuf};
28
29use clap::Parser;
30use memstead_base::filesystem::publish::assemble_archive;
31use serde_json::json;
32use tempfile::NamedTempFile;
33
34use crate::CliError;
35use crate::auth::{credentials, device_flow, resolve_token};
36use crate::output::{ExitKind, print_json, print_markdown};
37use crate::registry::{self, ApiErrorBody, PublishError};
38use crate::setup::{CliContext, CliEngine};
39
40#[derive(Parser, Debug)]
41pub struct Args {
42 #[arg(value_name = "PATH")]
46 pub archive: Option<PathBuf>,
47
48 #[arg(long, value_name = "PATH")]
52 pub workspace: Option<PathBuf>,
53
54 #[arg(long, value_name = "NAME")]
60 pub mem: Option<String>,
61
62 #[arg(long, value_name = "NAME")]
66 pub scope: Option<String>,
67
68 #[arg(long, value_name = "TOKEN")]
71 pub token: Option<String>,
72
73 #[arg(long, value_name = "URL")]
75 pub registry: Option<String>,
76
77 #[arg(long, value_name = "SEMVER")]
83 pub version: Option<String>,
84
85 #[arg(long)]
90 pub dry_run: bool,
91}
92
93pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
94 let base = registry::registry_base(args.registry.as_deref());
95 let host = registry::registry_host(&base);
96 let client = registry::build_http()?;
97
98 let target_version = match args.version.as_deref() {
103 Some(v) => {
104 if args.archive.is_some() {
105 return Err(CliError::new(
106 ExitKind::Validation,
107 "INVALID_INPUT",
108 "--version cannot be combined with a pre-built archive PATH (its version is already baked in) — drop the PATH and use --mem, or re-export at the new version",
109 )
110 .into());
111 }
112 if args.mem.is_none() {
113 return Err(CliError::new(
114 ExitKind::Validation,
115 "INVALID_INPUT",
116 "--version requires --mem <name> so the bump knows which mem to re-version",
117 )
118 .into());
119 }
120 Some(semver::Version::parse(v).map_err(|e| {
121 CliError::new(
122 ExitKind::Validation,
123 "INVALID_VERSION",
124 format!("--version {v:?} is not a valid semver: {e}"),
125 )
126 })?)
127 }
128 None => None,
129 };
130
131 let mut resolved_version: Option<String> = None;
140 let (archive_path, _tempfile_guard): (PathBuf, Option<NamedTempFile>) =
141 if let Some(p) = args.archive {
142 (p, None)
143 } else if let Some(mem_name) = args.mem.as_deref() {
144 let workspace_root = resolve_workspace_root(args.workspace.as_deref())?;
145 let mut engine = match ctx.cli_engine_at(&workspace_root)? {
146 #[cfg(feature = "mem-repo")]
147 CliEngine::MemRepo(e) => e,
148 CliEngine::Filesystem(e) => e,
149 };
150 if let Some(ver) = target_version.clone()
153 && !args.dry_run
154 {
155 engine
156 .set_mem_version(mem_name, ver, Some("version bump for registry publish"))
157 .map_err(CliError::from_engine_op)?;
158 }
159 resolved_version = target_version.as_ref().map(|v| v.to_string()).or_else(|| {
160 engine
161 .mem_config_for(mem_name)
162 .and_then(|c| c.version.clone())
163 .map(|v| v.to_string())
164 });
165 let bytes = engine
166 .export_mem_to_bytes(mem_name)
167 .map_err(CliError::from_engine_op)?;
168 stage_bytes_to_tempfile(&bytes)?
169 } else {
170 let workspace_root = resolve_workspace_root(args.workspace.as_deref())?;
171 let bytes = assemble_archive(&workspace_root).map_err(|e| {
172 CliError::new(
173 ExitKind::Validation,
174 "ARCHIVE_ASSEMBLY_FAILED",
175 format!("assemble archive: {e}"),
176 )
177 })?;
178 stage_bytes_to_tempfile(&bytes)?
179 };
180
181 if args.dry_run {
184 return emit_dry_run(
185 ctx,
186 &base,
187 &archive_path,
188 args.mem.as_deref(),
189 resolved_version.as_deref(),
190 args.scope.as_deref(),
191 );
192 }
193
194 if let Some(domain) = domain_scope(args.scope.as_deref()) {
199 let scope = args.scope.as_deref().expect("domain_scope implies a scope");
200 let sig = build_domain_signature(&archive_path, scope, &domain)?;
201 return match registry::publish(&client, &base, &archive_path, None, Some(scope), Some(&sig))
202 {
203 Ok(resp) => emit_success(ctx, &base, &resp),
204 Err(e) => Err(map_publish_error(e).into()),
205 };
206 }
207
208 let token = match resolve_token(&host, args.token.as_deref())? {
209 Some(r) => r.token,
210 None => {
211 if !std::io::stdin().is_terminal() {
212 return Err(CliError::new(
213 ExitKind::Generic,
214 "NOT_AUTHENTICATED",
215 "not logged in and stdin is not a TTY — set MEMSTEAD_TOKEN \
216 or run `memstead login` first",
217 )
218 .into());
219 }
220 login_inline(&client, &host)?
221 }
222 };
223
224 match registry::publish(
225 &client,
226 &base,
227 &archive_path,
228 Some(&token),
229 args.scope.as_deref(),
230 None,
231 ) {
232 Ok(resp) => emit_success(ctx, &base, &resp),
233 Err(e) => Err(map_publish_error(e).into()),
234 }
235}
236
237fn domain_scope(scope: Option<&str>) -> Option<String> {
241 let (prefix, handle) = scope?.split_once(':')?;
242 if prefix.contains('.') && !handle.is_empty() {
243 Some(prefix.to_ascii_lowercase())
244 } else {
245 None
246 }
247}
248
249#[cfg(feature = "mem-repo")]
253fn build_domain_signature(
254 archive_path: &Path,
255 scope: &str,
256 domain: &str,
257) -> anyhow::Result<registry::DomainSignature> {
258 use memstead_base::domain_authority_wire::signing_payload;
259 use memstead_git_branch::validator::validate_and_normalize_archive;
260 use sha2::{Digest, Sha256};
261
262 use crate::auth::domain_key;
263
264 let bytes = std::fs::read(archive_path).map_err(|e| {
265 CliError::new(
266 ExitKind::Generic,
267 "ARCHIVE_READ_FAILED",
268 format!("read archive: {e}"),
269 )
270 })?;
271 let validated = validate_and_normalize_archive(&bytes).map_err(|e| {
272 CliError::new(
273 ExitKind::Validation,
274 "ARCHIVE_INVALID",
275 format!("archive failed local validation before signing: {e}"),
276 )
277 })?;
278 let content_sha256 = {
279 let mut h = Sha256::new();
280 h.update(&validated.canonical_bytes);
281 h.finalize()
282 .iter()
283 .map(|b| format!("{b:02x}"))
284 .collect::<String>()
285 };
286 let name = validated.config.name.clone();
287 let version = validated.config.version.to_string();
288
289 let signing = domain_key::load(domain)
290 .map_err(|e| CliError::new(ExitKind::NotFound, "DOMAIN_KEY_NOT_FOUND", e.to_string()))?;
291 let timestamp = std::time::SystemTime::now()
292 .duration_since(std::time::UNIX_EPOCH)
293 .map(|d| d.as_secs() as i64)
294 .unwrap_or(0);
295 let payload = signing_payload(&content_sha256, scope, &name, &version, timestamp);
296 Ok(registry::DomainSignature {
297 key: domain_key::public_key_string(&signing),
298 signature: domain_key::sign(&signing, &payload),
299 timestamp,
300 })
301}
302
303#[cfg(not(feature = "mem-repo"))]
307fn build_domain_signature(
308 _archive_path: &Path,
309 _scope: &str,
310 _domain: &str,
311) -> anyhow::Result<registry::DomainSignature> {
312 Err(CliError::new(
313 ExitKind::Generic,
314 "DOMAIN_PUBLISH_UNAVAILABLE",
315 "domain publishing requires the full `memstead` build (the lean build cannot \
316 canonicalize archives for signing)",
317 )
318 .into())
319}
320
321fn emit_dry_run(
326 ctx: &CliContext,
327 base: &str,
328 archive_path: &Path,
329 mem: Option<&str>,
330 version: Option<&str>,
331 scope: Option<&str>,
332) -> anyhow::Result<()> {
333 let size = std::fs::metadata(archive_path)
334 .map(|m| m.len())
335 .unwrap_or(0);
336 let mem_label = mem.unwrap_or("(workspace mem)");
337 let version_label = version.unwrap_or("(from mem config / archive)");
338 if ctx.json {
339 print_json(&json!({
340 "dry_run": true,
341 "mem": mem,
342 "version": version,
343 "scope": scope,
344 "archive_bytes": size,
345 "registry": base,
346 "published": false,
347 }))?;
348 } else {
349 let scope_label = match scope {
350 Some(s) => format!("`{s}` (override)"),
351 None => "derived from your GitHub login".to_string(),
352 };
353 print_markdown(&format!(
354 "# Dry run — would publish\n\n\
355 - Mem: `{mem_label}`\n\
356 - Version: `{version_label}`\n\
357 - Scope: {scope_label}\n\
358 - Archive: {size} bytes\n\
359 - Registry: {base}\n\n\
360 Nothing was published and nothing was changed.",
361 ));
362 }
363 Ok(())
364}
365
366fn find_filesystem_workspace_root() -> anyhow::Result<PathBuf> {
371 let cwd = std::env::current_dir().map_err(|e| {
372 CliError::new(
373 ExitKind::Generic,
374 crate::INTERNAL_CODE,
375 format!("read cwd: {e}"),
376 )
377 })?;
378 let mut current: &Path = &cwd;
379 loop {
380 if memstead_base::is_workspace_root(current) {
381 return Ok(current.to_path_buf());
382 }
383 match current.parent() {
384 Some(p) => current = p,
385 None => {
386 return Err(CliError::new(
387 ExitKind::NotFound,
388 "WORKSPACE_NOT_INITIALISED",
389 format!(
390 "no workspace found from {} or any ancestor (missing \
391 .memstead/workspace.toml) — run `memstead init` first, pass \
392 --workspace <path>, or supply an archive path",
393 cwd.display()
394 ),
395 )
396 .into());
397 }
398 }
399 }
400}
401
402fn resolve_workspace_root(workspace: Option<&Path>) -> anyhow::Result<PathBuf> {
406 match workspace {
407 Some(p) => {
408 let canon = p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
409 if !memstead_base::is_workspace_root(&canon) {
410 return Err(CliError::new(
411 ExitKind::NotFound,
412 "WORKSPACE_NOT_INITIALISED",
413 format!(
414 "no workspace at {} (missing .memstead/workspace.toml)",
415 canon.display()
416 ),
417 )
418 .into());
419 }
420 Ok(canon)
421 }
422 None => find_filesystem_workspace_root(),
423 }
424}
425
426fn stage_bytes_to_tempfile(bytes: &[u8]) -> anyhow::Result<(PathBuf, Option<NamedTempFile>)> {
430 let tempfile = NamedTempFile::new().map_err(|e| {
431 CliError::new(
432 ExitKind::Generic,
433 crate::INTERNAL_CODE,
434 format!("tempfile: {e}"),
435 )
436 })?;
437 std::fs::write(tempfile.path(), bytes).map_err(|e| {
438 CliError::new(
439 ExitKind::Generic,
440 crate::INTERNAL_CODE,
441 format!("write tempfile {}: {e}", tempfile.path().display()),
442 )
443 })?;
444 let path = tempfile.path().to_path_buf();
445 Ok((path, Some(tempfile)))
446}
447
448fn login_inline(client: &reqwest::blocking::Client, host: &str) -> anyhow::Result<String> {
449 println!("Not logged in — starting GitHub Device Flow…");
450 let outcome = device_flow::run(
451 client,
452 device_flow::MEMSTEAD_GITHUB_CLIENT_ID,
453 device_flow::MEMSTEAD_GITHUB_SCOPE,
454 |url| {
455 let _ = device_flow::open_browser(url);
456 },
457 )
458 .map_err(|e| {
459 CliError::new(
460 ExitKind::Generic,
461 "LOGIN_FAILED",
462 format!("login failed: {e}"),
463 )
464 })?;
465
466 let user_login = fetch_login(client, &outcome.access_token).unwrap_or_default();
468
469 let entry = credentials::Entry::new(
470 outcome.access_token.clone(),
471 user_login,
472 outcome.scopes.clone(),
473 );
474 credentials::save_for(host, entry)?;
475
476 Ok(outcome.access_token)
477}
478
479fn fetch_login(client: &reqwest::blocking::Client, token: &str) -> anyhow::Result<String> {
480 let base = std::env::var("MEMSTEAD_GITHUB_API_BASE")
481 .unwrap_or_else(|_| "https://api.github.com".to_string());
482 let url = format!("{}/user", base.trim_end_matches('/'));
483 let resp = client
484 .get(url)
485 .bearer_auth(token)
486 .header("accept", "application/vnd.github+json")
487 .send()?;
488 if !resp.status().is_success() {
489 anyhow::bail!("GitHub /user returned {}", resp.status());
490 }
491 #[derive(serde::Deserialize)]
492 struct User {
493 login: String,
494 }
495 let user: User = resp.json()?;
496 Ok(user.login)
497}
498
499fn emit_success(
500 ctx: &CliContext,
501 base: &str,
502 resp: ®istry::PublishResponse,
503) -> anyhow::Result<()> {
504 let full_url = format!("{}{}", base, resp.url);
505 let demoted = resp.current.as_deref().filter(|cur| *cur != resp.version);
510 if ctx.json {
511 print_json(&json!({
512 "ok": true,
513 "scope": resp.scope,
514 "name": resp.name,
515 "version": resp.version,
516 "current": resp.current,
517 "url": full_url,
518 }))?;
519 } else {
520 let mut block = format!(
521 "# Published {}/{} v{}\n\n- URL: {}",
522 resp.scope, resp.name, resp.version, full_url,
523 );
524 if let Some(cur) = demoted {
525 block.push_str(&format!(
526 "\n\n> Note: `current` stays at v{cur} — you published an older version, \
527 so it is retained and resolvable but is not the default users get.",
528 ));
529 }
530 print_markdown(&block);
531 }
532 Ok(())
533}
534
535fn map_publish_error(err: PublishError) -> CliError {
536 match err {
537 PublishError::Io(e) => CliError::new(
538 ExitKind::Generic,
539 "ARCHIVE_READ_FAILED",
540 format!("cannot read archive: {e}"),
541 ),
542 PublishError::Network(e) => CliError::new(
543 ExitKind::Generic,
544 "NETWORK_ERROR",
545 format!("network error: {e}"),
546 ),
547 PublishError::Malformed(e) => CliError::new(
548 ExitKind::Generic,
549 "REGISTRY_MALFORMED_RESPONSE",
550 format!("registry sent an unparseable success response: {e}"),
551 ),
552 PublishError::Raw { status, text } => CliError::new(
553 ExitKind::Generic,
554 "REGISTRY_ERROR",
555 format!("registry returned {status}: {text}"),
556 ),
557 PublishError::Api { status, envelope } => map_api_error(status, envelope),
558 }
559}
560
561fn map_api_error(status: reqwest::StatusCode, envelope: ApiErrorBody) -> CliError {
562 let kind = match status.as_u16() {
563 400 => ExitKind::Validation,
564 401 | 403 => ExitKind::Generic,
565 404 => ExitKind::NotFound,
566 410 => ExitKind::Generic,
567 413 | 429 => ExitKind::Generic,
568 _ => ExitKind::Generic,
569 };
570 let code: &'static str = match status.as_u16() {
571 400 => "REGISTRY_VALIDATION_FAILED",
572 401 => "NOT_AUTHENTICATED",
573 403 => "FORBIDDEN",
574 404 => "REGISTRY_NOT_FOUND",
575 410 => "GONE",
576 413 => "ARCHIVE_TOO_LARGE",
577 429 => "RATE_LIMITED",
578 _ => "REGISTRY_ERROR",
579 };
580
581 let mut msg = match status.as_u16() {
582 400 => {
583 let variant = envelope
584 .variant
585 .clone()
586 .unwrap_or_else(|| "ValidationFailed".to_string());
587 let detail = envelope
588 .detail
589 .clone()
590 .unwrap_or_else(|| "validation failed (no detail)".to_string());
591 if let Some(path) = envelope.path.as_deref() {
592 format!("{variant} at {path}: {detail}")
593 } else {
594 format!("{variant}: {detail}")
595 }
596 }
597 401 => {
598 "unauthorized — set MEMSTEAD_TOKEN, run `memstead login`, or pass --token".to_string()
599 }
600 403 => envelope
601 .detail
602 .clone()
603 .map(|d| format!("forbidden: {d}"))
604 .unwrap_or_else(|| "forbidden".to_string()),
605 404 => "registry returned 404 — is the URL correct?".to_string(),
606 410 => envelope
607 .detail
608 .clone()
609 .map(|d| format!("gone: {d}"))
610 .unwrap_or_else(|| "content is gone (taken down or deny-listed)".to_string()),
611 413 => "archive exceeds the 2 MB publisher cap".to_string(),
612 429 => {
613 let retry = envelope.retry_after_seconds.unwrap_or(0);
614 if retry > 0 {
615 format!("rate-limited — retry after {retry}s")
616 } else {
617 "rate-limited".to_string()
618 }
619 }
620 _ => envelope
621 .detail
622 .clone()
623 .unwrap_or_else(|| format!("registry returned {status}")),
624 };
625
626 if !envelope.error.is_empty() && !msg.to_ascii_lowercase().contains(&envelope.error) {
629 msg = format!("{msg} [{}]", envelope.error);
630 }
631
632 CliError::new(kind, code, msg)
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638 use memstead_base::filesystem::config::{WorkspaceConfig, write_workspace_config};
639 use memstead_schema::SchemaRef;
640 use tempfile::TempDir;
641
642 fn write_publishable_workspace(tmp: &TempDir, name: &str) {
643 let memstead_dir = tmp.path().join(".memstead");
646 std::fs::create_dir_all(&memstead_dir).unwrap();
647 std::fs::write(
648 memstead_dir.join("workspace.toml"),
649 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
650 )
651 .unwrap();
652 let pin: SchemaRef = "default@1.0.0".parse().unwrap();
657 let cfg = WorkspaceConfig::new(name, pin);
658 write_workspace_config(tmp.path(), &cfg).unwrap();
659 let cfg_path = tmp.path().join(".memstead").join("config.json");
663 let raw = std::fs::read_to_string(&cfg_path).unwrap();
664 let mut value: serde_json::Value = serde_json::from_str(&raw).unwrap();
665 value["version"] = serde_json::json!("0.1.0");
666 std::fs::write(&cfg_path, serde_json::to_string_pretty(&value).unwrap()).unwrap();
667 }
668
669 async fn spawn_fixture_publish_registry() -> (
674 String,
675 std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
676 tokio::task::JoinHandle<()>,
677 ) {
678 use axum::{Json, Router, extract::State, http::StatusCode, routing::post};
679 use std::sync::{Arc, Mutex};
680
681 let captured: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
682 let captured_clone = captured.clone();
683 let app: Router = Router::new()
684 .route(
685 "/api/publish",
686 post(
687 move |State(buf): State<Arc<Mutex<Vec<u8>>>>, body: axum::body::Bytes| async move {
688 *buf.lock().unwrap() = body.to_vec();
689 (
690 StatusCode::OK,
691 Json(serde_json::json!({
692 "ok": true,
693 "scope": "fixture",
694 "name": "demo",
695 "version": "0.1.0",
696 "url": "/v/fixture/demo",
697 })),
698 )
699 },
700 ),
701 )
702 .with_state(captured_clone);
703 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
704 let addr = listener.local_addr().unwrap();
705 let handle = tokio::spawn(async move {
706 axum::serve(listener, app).await.unwrap();
707 });
708 (format!("http://{addr}"), captured, handle)
709 }
710
711 #[tokio::test(flavor = "multi_thread")]
712 async fn publish_assembles_from_workspace_when_no_archive_arg() {
713 let tmp = TempDir::new().unwrap();
714 write_publishable_workspace(&tmp, "demo");
715
716 let (base, captured, handle) = spawn_fixture_publish_registry().await;
717
718 let workspace = tmp.path().to_path_buf();
719 let base_clone = base.clone();
720 let captured_clone = captured.clone();
721 let result = tokio::task::spawn_blocking(move || {
722 let ctx = CliContext {
723 json: false,
724 quiet: false,
725 };
726 run(
727 &ctx,
728 Args {
729 archive: None,
730 workspace: Some(workspace),
731 mem: None,
732 scope: None,
733 version: None,
734 dry_run: false,
735 token: Some("fixture-token".to_string()),
736 registry: Some(base_clone),
737 },
738 )?;
739 let body = captured_clone.lock().unwrap().clone();
740 Ok::<Vec<u8>, anyhow::Error>(body)
741 })
742 .await
743 .unwrap();
744 handle.abort();
745 let body = result.unwrap();
746
747 assert!(body.len() > 4);
749 assert_eq!(&body[0..4], b"PK\x03\x04");
750 }
751
752 #[test]
753 fn publish_rejects_when_workspace_override_lacks_config() {
754 let tmp = TempDir::new().unwrap();
755 let ctx = CliContext {
757 json: false,
758 quiet: false,
759 };
760 let err = run(
761 &ctx,
762 Args {
763 archive: None,
764 workspace: Some(tmp.path().to_path_buf()),
765 mem: None,
766 scope: None,
767 version: None,
768 dry_run: false,
769 token: Some("fixture-token".to_string()),
770 registry: Some("http://127.0.0.1:1".to_string()),
771 },
772 )
773 .unwrap_err();
774 let msg = err.to_string();
775 assert!(
776 msg.contains("no workspace") || msg.contains("missing .memstead/workspace.toml"),
777 "expected workspace-not-found error, got: {msg}"
778 );
779 }
780
781 #[test]
782 fn publish_mem_flag_routes_through_engine_and_maps_unknown_mem() {
783 let tmp = TempDir::new().unwrap();
793 write_publishable_workspace(&tmp, "demo");
794 let ctx = CliContext {
795 json: false,
796 quiet: false,
797 };
798 let err = run(
799 &ctx,
800 Args {
801 archive: None,
802 workspace: Some(tmp.path().to_path_buf()),
803 mem: Some("nonexistent".to_string()),
804 scope: None,
805 version: None,
806 dry_run: false,
807 token: Some("fixture-token".to_string()),
808 registry: Some("http://127.0.0.1:1".to_string()),
809 },
810 )
811 .unwrap_err();
812 let msg = err.to_string();
813 assert!(
814 msg.contains("unknown mem") || msg.contains("nonexistent"),
815 "expected an engine UNKNOWN_MEM error from the --mem path, got: {msg}"
816 );
817 }
818
819 #[test]
820 fn publish_version_without_mem_is_rejected_before_any_io() {
821 let ctx = CliContext {
825 json: false,
826 quiet: false,
827 };
828 let err = run(
829 &ctx,
830 Args {
831 archive: None,
832 workspace: None,
833 mem: None,
834 scope: None,
835 version: Some("0.2.0".to_string()),
836 dry_run: false,
837 token: None,
838 registry: Some("http://127.0.0.1:1".to_string()),
839 },
840 )
841 .unwrap_err();
842 let msg = err.to_string();
843 assert!(
844 msg.contains("--version requires --mem"),
845 "expected a --version-requires-mem refusal, got: {msg}"
846 );
847 }
848
849 #[tokio::test(flavor = "multi_thread")]
850 async fn dry_run_posts_nothing() {
851 let tmp = TempDir::new().unwrap();
855 write_publishable_workspace(&tmp, "demo");
856
857 let (base, captured, handle) = spawn_fixture_publish_registry().await;
858
859 let workspace = tmp.path().to_path_buf();
860 let base_clone = base.clone();
861 let result = tokio::task::spawn_blocking(move || {
862 let ctx = CliContext {
863 json: false,
864 quiet: false,
865 };
866 run(
867 &ctx,
868 Args {
869 archive: None,
870 workspace: Some(workspace),
871 mem: None,
872 scope: None,
873 version: None,
874 dry_run: true,
875 token: None,
876 registry: Some(base_clone),
877 },
878 )?;
879 Ok::<(), anyhow::Error>(())
880 })
881 .await
882 .unwrap();
883 handle.abort();
884 result.unwrap();
885
886 assert!(
887 captured.lock().unwrap().is_empty(),
888 "dry-run must not POST anything to the registry"
889 );
890 }
891}