/
githubmirror
/
deno
Обзор
Документация
Войти
/
githubmirror
/
deno
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
cli/tools/publish/mod.rs
1 678 строк
50 KB
Leo Kettmeir
fix(publish): report when the registry rejects a provenance attestation (#36404)
04 авг 2026, 22:46
Не верифицирован
04 авг 2026, 22:46
76d48f6
Код
Авторство
О чём код?
// Copyright 2018-2026 the Deno authors. MIT license. use std::collections::HashMap; use std::collections::HashSet; use std::io::IsTerminal; use std::path::Path; use std::path::PathBuf; use std::rc::Rc; use std::sync::Arc; use base64::Engine; use base64::prelude::BASE64_STANDARD; use deno_ast::ModuleSpecifier; use deno_ast::SourceTextInfo; use deno_config::deno_json::ConfigFile; use deno_config::workspace::JsrPackageConfig; use deno_config::workspace::Workspace; use deno_core::anyhow::Context; use deno_core::anyhow::bail; use deno_core::error::AnyError; use deno_core::futures::FutureExt; use deno_core::futures::StreamExt; use deno_core::futures::future::LocalBoxFuture; use deno_core::futures::stream::FuturesUnordered; use deno_core::serde_json; use deno_core::serde_json::Value; use deno_core::serde_json::json; use deno_core::url::Url; use deno_resolver::collections::FolderScopedMap; use deno_runtime::deno_fetch; use deno_terminal::colors; use http_body_util::BodyExt; use serde::Deserialize; use serde::Serialize; use sha2::Digest; use self::diagnostics::PublishDiagnostic; use self::diagnostics::PublishDiagnosticsCollector; use self::diagnostics::RelativePackageImportDiagnosticReferrer; use self::graph::GraphDiagnosticsCollector; use self::module_content::ModuleContentProvider; use self::paths::CollectedPublishPath; use self::tar::PublishableTarball; use crate::args::CliOptions; use crate::args::Flags; use crate::args::PublishFlags; use crate::args::jsr_api_url; use crate::args::jsr_url; use crate::factory::CliFactory; use crate::graph_util::CreatePublishGraphOptions; use crate::graph_util::ModuleGraphCreator; use crate::http_util::HttpClient; use crate::registry; use crate::tools::lint::collect_no_slow_type_diagnostics; use crate::type_checker::CheckOptions; use crate::type_checker::TypeChecker; use crate::util::display::human_size; use crate::util::git::check_if_git_repo_dirty; mod auth; mod diagnostics; mod graph; mod module_content; mod paths; mod provenance; mod publish_order; mod tar; mod unfurl; mod wasm; use auth::AuthMethod; use auth::get_auth_method; use publish_order::PublishOrderGraph; use unfurl::SpecifierUnfurler; pub async fn publish( flags: Arc<Flags>, publish_flags: PublishFlags, ) -> Result<(), AnyError> { let cli_factory = CliFactory::from_flags(flags); let auth_method = get_auth_method(publish_flags.token, publish_flags.dry_run)?; let cli_options = cli_factory.cli_options()?; let directory_path = cli_options.initial_cwd(); let mut publish_configs = cli_options.start_dir.jsr_packages_for_publish(); if publish_configs.is_empty() { match cli_options.start_dir.member_deno_json() { Some(deno_json) => { debug_assert!(!deno_json.is_package() || !deno_json.should_publish()); if deno_json.json.name.is_none() { bail!("Missing 'name' field in '{}'.", deno_json.specifier); } if !deno_json.should_publish() { bail!( "Package 'publish' field is false in '{}'.", deno_json.specifier ); } error_missing_exports_field(deno_json)?; } None => { bail!( "Couldn't find a deno.json, deno.jsonc, jsr.json or jsr.jsonc configuration file in {}.", directory_path.display() ); } } } if let Some(version) = &publish_flags.set_version { if publish_configs.len() > 1 { bail!( "Cannot use --set-version when publishing a workspace. Change your cwd to an individual package instead." ); } if let Some(publish_config) = publish_configs.get_mut(0) { let mut config_file = publish_config.config_file.as_ref().clone(); config_file.json.version = Some(version.clone()); publish_config.config_file = Arc::new(config_file); } } // Bail out early if the version is already published, before doing the // expensive type checking and tarball preparation. Already-published // versions are skipped with a warning (rather than erroring) so that // re-running `deno publish` after a partial or concurrent publish still // succeeds, matching the idempotent behavior of the publish step itself. if !publish_flags.dry_run { publish_configs = filter_out_published_packages( &cli_factory.http_client_provider().get_or_create()?, jsr_api_url(), publish_configs, ) .await?; if publish_configs.is_empty() { log::info!( "{} All packages are already published", colors::green("Success"), ); return Ok(()); } } let specifier_unfurler = SpecifierUnfurler::new( cli_factory.node_resolver().await?.clone(), cli_factory.npm_req_resolver().await?.clone(), cli_factory.pkg_json_resolver()?.clone(), cli_factory.cli_options().unwrap().start_dir.clone(), cli_factory.workspace_resolver().await?.clone(), ); let diagnostics_collector = PublishDiagnosticsCollector::default(); let parsed_source_cache = cli_factory.parsed_source_cache()?; let module_content_provider = Arc::new(ModuleContentProvider::new( parsed_source_cache.clone(), specifier_unfurler, cli_factory.sys(), cli_factory.compiler_options_resolver()?.clone(), )); let publish_preparer = PublishPreparer::new( GraphDiagnosticsCollector::new( cli_factory.npm_resolver().await?.clone(), parsed_source_cache.clone(), ), cli_factory.module_graph_creator().await?.clone(), cli_factory.type_checker().await?.clone(), cli_options.clone(), module_content_provider, ); let prepared_data = publish_preparer .prepare_packages_for_publishing( publish_flags.allow_slow_types, &diagnostics_collector, publish_configs, ) .await?; diagnostics_collector.print_and_error()?; if prepared_data.package_by_name.is_empty() { bail!("No packages to publish"); } if std::env::var("DENO_TESTING_DISABLE_GIT_CHECK") .ok() .is_none() && !publish_flags.allow_dirty && let Some(dirty_text) = check_if_git_repo_dirty(cli_options.initial_cwd()).await { log::error!("\nUncommitted changes:\n\n{}\n", dirty_text); bail!( "Aborting due to uncommitted changes. Check in source code or run with --allow-dirty" ); } if publish_flags.dry_run { for (_, package) in prepared_data.package_by_name { log::info!( "{} of {} with files:", colors::green_bold("Simulating publish"), colors::gray(package.display_name()), ); for file in &package.tarball.files { log::info!(" {} ({})", file.specifier, human_size(file.size as f64),); } } log::warn!("{} Dry run complete", colors::green("Success")); return Ok(()); } perform_publish( &cli_factory.http_client_provider().get_or_create()?, prepared_data.publish_order_graph, prepared_data.package_by_name, auth_method, !publish_flags.no_provenance, ) .await?; Ok(()) } /// Queries the registry for each package's version concurrently and returns the /// subset of configs whose versions are not yet published. Already-published /// versions are reported with a warning and dropped from the returned list. async fn filter_out_published_packages( client: &HttpClient, registry_api_url: &Url, publish_configs: Vec<JsrPackageConfig>, ) -> Result<Vec<JsrPackageConfig>, AnyError> { let checks = publish_configs.iter().map(|config| async move { let Some(version) = config.config_file.json.version.as_deref() else { // a missing version is reported with a better error later on return Ok::<bool, AnyError>(false); }; let Ok((scope, package)) = registry::parse_package_name(&config.name) else { // an invalid package name is reported with a better error later on return Ok(false); }; registry::check_version_exists( client, registry_api_url, scope, package, version, ) .await }); let results = deno_core::futures::future::join_all(checks).await; let mut remaining = Vec::with_capacity(publish_configs.len()); for (config, already_published) in publish_configs.into_iter().zip(results) { if already_published? { log::info!( "{} {}@{}", colors::yellow("Warning: Skipping, already published"), config.name, config.config_file.json.version.as_deref().unwrap_or(""), ); } else { remaining.push(config); } } Ok(remaining) } struct PreparedPublishPackage { scope: String, package: String, version: String, tarball: PublishableTarball, config: String, exports: HashMap<String, String>, } impl PreparedPublishPackage { pub fn display_name(&self) -> String { format!("@{}/{}@{}", self.scope, self.package, self.version) } } struct PreparePackagesData { publish_order_graph: PublishOrderGraph, package_by_name: HashMap<String, Rc<PreparedPublishPackage>>, } struct PublishPreparer { graph_diagnostics_collector: GraphDiagnosticsCollector, module_graph_creator: Arc<ModuleGraphCreator>, type_checker: Arc<TypeChecker>, cli_options: Arc<CliOptions>, module_content_provider: Arc<ModuleContentProvider>, } impl PublishPreparer { pub fn new( graph_diagnostics_collector: GraphDiagnosticsCollector, module_graph_creator: Arc<ModuleGraphCreator>, type_checker: Arc<TypeChecker>, cli_options: Arc<CliOptions>, module_content_provider: Arc<ModuleContentProvider>, ) -> Self { Self { graph_diagnostics_collector, module_graph_creator, type_checker, cli_options, module_content_provider, } } pub async fn prepare_packages_for_publishing( &self, allow_slow_types: bool, diagnostics_collector: &PublishDiagnosticsCollector, publish_configs: Vec<JsrPackageConfig>, ) -> Result<PreparePackagesData, AnyError> { if publish_configs.len() > 1 { log::info!("Publishing a workspace..."); } // create the module graph let graph = self .build_and_check_graph_for_publish( allow_slow_types, diagnostics_collector, &publish_configs, ) .await?; let mut package_by_name = HashMap::with_capacity(publish_configs.len()); let publish_order_graph = publish_order::build_publish_order_graph(&graph, &publish_configs)?; let results = publish_configs .into_iter() .map(|member| { let graph = graph.clone(); async move { let package = self .prepare_publish(&member, graph, diagnostics_collector) .await .with_context(|| format!("Failed preparing '{}'.", member.name))?; Ok::<_, AnyError>((member.name, package)) } .boxed() }) .collect::<Vec<_>>(); let results = deno_core::futures::future::join_all(results).await; for result in results { let (package_name, package) = result?; package_by_name.insert(package_name, package); } Ok(PreparePackagesData { publish_order_graph, package_by_name, }) } async fn build_and_check_graph_for_publish( &self, allow_slow_types: bool, diagnostics_collector: &PublishDiagnosticsCollector, package_configs: &[JsrPackageConfig], ) -> Result<Arc<deno_graph::ModuleGraph>, deno_core::anyhow::Error> { let build_fast_check_graph = !allow_slow_types; let graph = self .module_graph_creator .create_publish_graph(CreatePublishGraphOptions { packages: package_configs, build_fast_check_graph, validate_graph: true, skip_unanalyzable_exports: false, }) .await?; // todo(dsherret): move to lint rule self .graph_diagnostics_collector .collect_diagnostics_for_graph(&graph, diagnostics_collector)?; if allow_slow_types { log::info!( concat!( "{} Publishing a library with slow types is not recommended. ", "This may lead to poor type checking performance for users of ", "your package, may affect the quality of automatic documentation ", "generation, and your package will not be shipped with a .d.ts ", "file for Node.js users." ), colors::yellow("Warning"), ); Ok(Arc::new(graph)) } else if std::env::var("DENO_INTERNAL_FAST_CHECK_OVERWRITE").as_deref() == Ok("1") { if check_if_git_repo_dirty(self.cli_options.initial_cwd()) .await .is_some() { bail!( "When using DENO_INTERNAL_FAST_CHECK_OVERWRITE, the git repo must be in a clean state." ); } for module in graph.modules() { if module.specifier().scheme() != "file" { continue; } let Some(js) = module.js() else { continue; }; if let Some(module) = js.fast_check_module() { std::fs::write( js.specifier.to_file_path().unwrap(), module.source.as_ref(), )?; } } bail!("Exiting due to DENO_INTERNAL_FAST_CHECK_OVERWRITE") } else { log::info!("Checking for slow types in the public API..."); for package in package_configs { let export_urls = package.config_file.resolve_export_value_urls()?; let diagnostics = collect_no_slow_type_diagnostics(&graph, &export_urls); if !diagnostics.is_empty() { for diagnostic in diagnostics { diagnostics_collector .push(PublishDiagnostic::FastCheck(diagnostic)); } } } // skip type checking the slow type graph if there are any errors because // errors like remote modules existing will cause type checking to crash if diagnostics_collector.has_error() { Ok(Arc::new(graph)) } else { // fast check passed, type check the output as a temporary measure // until we know that it's reliable and stable let mut diagnostics_by_folder = self.type_checker.check_diagnostics( graph, CheckOptions { build_fast_check_graph: false, // already built lib: self.cli_options.ts_type_lib_window(), reload: self.cli_options.reload_flag(), type_check_mode: self.cli_options.type_check_mode(), }, )?; // ignore unused (type) parameter diagnostics that may occur due to fast // check not having function body implementations for result in diagnostics_by_folder.by_ref() { let check_diagnostics = result?; let check_diagnostics = check_diagnostics.filter(|d| d.include_when_remote()); if check_diagnostics.has_diagnostic() { bail!( concat!( "Failed ensuring public API type output is valid.\n\n", "{:#}\n\n", "You may have discovered a bug in Deno. Please open an issue at: ", "https://github.com/denoland/deno/issues/" ), check_diagnostics ); } } Ok(diagnostics_by_folder.into_graph()) } } } async fn prepare_publish( &self, package: &JsrPackageConfig, graph: Arc<deno_graph::ModuleGraph>, diagnostics_collector: &PublishDiagnosticsCollector, ) -> Result<Rc<PreparedPublishPackage>, AnyError> { let deno_json = &package.config_file; let config_path = deno_json.specifier.to_file_path().unwrap(); let root_dir = config_path.parent().unwrap().to_path_buf(); let version = deno_json.json.version.clone().ok_or_else(|| { deno_core::anyhow::anyhow!( "{} is missing 'version' field", deno_json.specifier ) })?; let (scope, name_no_scope) = registry::parse_package_name(&package.name)?; let file_patterns = package.member_dir.to_publish_config()?.files; let tarball = deno_core::unsync::spawn_blocking({ let diagnostics_collector = diagnostics_collector.clone(); let module_content_provider = self.module_content_provider.clone(); let cli_options = self.cli_options.clone(); let config_path = config_path.clone(); let config_url = deno_json.specifier.clone(); let has_license_field = package.license.is_some(); let current_package_name = package.name.clone(); move || { let root_specifier = ModuleSpecifier::from_directory_path(&root_dir).unwrap(); let mut publish_paths = paths::collect_publish_paths(paths::CollectPublishPathsOptions { root_dir: &root_dir, cli_options: &cli_options, diagnostics_collector: &diagnostics_collector, file_patterns, force_include_paths: vec![config_path], })?; let all_jsr_packages = FolderScopedMap::from_map( cli_options .workspace() .jsr_packages() .map(|pkg| (pkg.member_dir.dir_url().clone(), pkg)) .collect(), ); collect_excluded_module_diagnostics( &root_specifier, &graph, ¤t_package_name, &all_jsr_packages, &publish_paths, &diagnostics_collector, ); if !has_license_field && !has_license_file(publish_paths.iter().map(|p| &p.specifier)) { if let Some(license_path) = resolve_license_file(&root_dir, cli_options.workspace()) { // force including the license file from the package or workspace root publish_paths.push(CollectedPublishPath { specifier: ModuleSpecifier::from_file_path(&license_path) .unwrap(), relative_path: "/LICENSE".to_string(), maybe_content: Some(std::fs::read(&license_path).with_context( || format!("failed reading '{}'.", license_path.display()), )?), path: license_path, }); } else { diagnostics_collector.push(PublishDiagnostic::MissingLicense { config_specifier: config_url, }); } } tar::create_gzipped_tarball( &module_content_provider, &graph, &diagnostics_collector, publish_paths, ) .context("Failed to create a tarball") } }) .await??; log::debug!("Tarball size ({}): {}", package.name, tarball.bytes.len()); Ok(Rc::new(PreparedPublishPackage { scope: scope.to_string(), package: name_no_scope.to_string(), version: version.to_string(), tarball, exports: match &deno_json.json.exports { Some(Value::Object(exports)) => exports .into_iter() .map(|(k, v)| (k.to_string(), v.as_str().unwrap().to_string())) .collect(), Some(Value::String(exports)) => { let mut map = HashMap::new(); map.insert(".".to_string(), exports.to_string()); map } _ => HashMap::new(), }, // the config file is always at the root of a publishing dir, // so getting the file name is always correct config: config_path .file_name() .unwrap() .to_string_lossy() .into_owned(), })) } } #[derive(Serialize)] #[serde(tag = "permission")] pub enum Permission<'s> { #[serde(rename = "package/publish", rename_all = "camelCase")] VersionPublish { scope: &'s str, package: &'s str, version: &'s str, tarball_hash: &'s str, }, } async fn get_auth_headers( client: &HttpClient, registry_url: &Url, packages: &[Rc<PreparedPublishPackage>], auth_method: AuthMethod, ) -> Result<HashMap<(String, String, String), Rc<str>>, AnyError> { let permissions = packages .iter() .map(|package| Permission::VersionPublish { scope: &package.scope, package: &package.package, version: &package.version, tarball_hash: &package.tarball.hash, }) .collect::<Vec<_>>(); let mut authorizations = HashMap::with_capacity(packages.len()); match auth_method { AuthMethod::Interactive => { let verifier = uuid::Uuid::new_v4().to_string(); let challenge = BASE64_STANDARD.encode(sha2::Sha256::digest(&verifier)); let response = client .post_json( format!("{}authorizations", registry_url).parse()?, &serde_json::json!({ "challenge": challenge, "permissions": permissions, }), )? .send() .await .context("Failed to create interactive authorization")?; let auth = registry::parse_response::< registry::CreateAuthorizationResponse, >(response) .await .context("Failed to create interactive authorization")?; let auth_url = format!("{}?code={}", auth.verification_url, auth.code); let pkgs_text = if packages.len() > 1 { format!("{} packages", packages.len()) } else { format!("@{}/{}", packages[0].scope, packages[0].package) }; log::warn!( "Visit {} to authorize publishing of {}", colors::cyan(&auth_url), pkgs_text, ); ring_bell(); log::info!("{}", colors::gray("Waiting...")); let _ = open::that_detached(&auth_url); let interval = std::time::Duration::from_secs(auth.poll_interval); loop { tokio::time::sleep(interval).await; let response = client .post_json( format!("{}authorizations/exchange", registry_url).parse()?, &serde_json::json!({ "exchangeToken": auth.exchange_token, "verifier": verifier, }), )? .send() .await .context("Failed to exchange authorization")?; let res = registry::parse_response::< registry::ExchangeAuthorizationResponse, >(response) .await; match res { Ok(res) => { log::info!( "{} {} {}", colors::green("Authorization successful."), colors::gray("Authenticated as"), colors::cyan(res.user.name) ); let authorization: Rc<str> = format!("Bearer {}", res.token).into(); for pkg in packages { authorizations.insert( (pkg.scope.clone(), pkg.package.clone(), pkg.version.clone()), authorization.clone(), ); } break; } Err(err) => { if err.code == "authorizationPending" { continue; } else { return Err(err).context("Failed to exchange authorization"); } } } } } AuthMethod::Token(token) => { let authorization: Rc<str> = format!("Bearer {}", token).into(); for pkg in packages { authorizations.insert( (pkg.scope.clone(), pkg.package.clone(), pkg.version.clone()), authorization.clone(), ); } } AuthMethod::Oidc(oidc_config) => { let mut chunked_packages = packages.chunks(16); for permissions in permissions.chunks(16) { let audience = json!({ "permissions": permissions }).to_string(); let url = format!( "{}&audience={}", oidc_config.url, percent_encoding::percent_encode( audience.as_bytes(), percent_encoding::NON_ALPHANUMERIC ) ); let response = client .get(url.parse()?)? .header( http::header::AUTHORIZATION, format!("Bearer {}", oidc_config.token).parse()?, ) .send() .await .context("Failed to get OIDC token")?; let status = response.status(); let text = crate::http_util::body_to_string(response) .await .with_context(|| { format!("Failed to get OIDC token: status {}", status) })?; if !status.is_success() { bail!( "Failed to get OIDC token: status {}, response: '{}'", status, text ); } let registry::OidcTokenResponse { value } = serde_json::from_str(&text) .with_context(|| { format!( "Failed to parse OIDC token: '{}' (status {})", text, status ) })?; let authorization: Rc<str> = format!("githuboidc {}", value).into(); for pkg in chunked_packages.next().unwrap() { authorizations.insert( (pkg.scope.clone(), pkg.package.clone(), pkg.version.clone()), authorization.clone(), ); } } } }; Ok(authorizations) } #[derive(Debug)] struct CreatePackageInfo { scope: String, package: String, create_url: String, } /// Check if both `scope` and `package` already exist, if not return /// a URL to the management panel to create them. async fn check_if_scope_and_package_exist( client: &HttpClient, registry_api_url: &Url, registry_manage_url: &Url, scope: &str, package: &str, ) -> Result<Option<CreatePackageInfo>, AnyError> { let response = registry::get_package(client, registry_api_url, scope, package).await?; if response.status() == 404 { let create_url = format!( "{}new?scope={}&package={}&from=cli", registry_manage_url, scope, package ); Ok(Some(CreatePackageInfo { create_url, scope: scope.to_string(), package: package.to_string(), })) } else { Ok(None) } } async fn ensure_scopes_and_packages_exist( client: &HttpClient, registry_api_url: &Url, registry_manage_url: &Url, packages: &[Rc<PreparedPublishPackage>], ) -> Result<(), AnyError> { let mut futures = FuturesUnordered::new(); for package in packages { let future = check_if_scope_and_package_exist( client, registry_api_url, registry_manage_url, &package.scope, &package.package, ); futures.push(future); } let mut missing_packages = vec![]; while let Some(maybe_create_package_info) = futures.next().await { if let Some(create_package_info) = maybe_create_package_info? { missing_packages.push(create_package_info); }; } if !std::io::stdin().is_terminal() { let missing_packages_lines: Vec<_> = missing_packages .into_iter() .map(|info| format!("- {}", info.create_url)) .collect(); if !missing_packages_lines.is_empty() { bail!( "Following packages don't exist, follow the links and create them:\n{}", missing_packages_lines.join("\n") ); } return Ok(()); } for create_package_info in missing_packages { ring_bell(); log::warn!( "'@{}/{}' doesn't exist yet. Visit {} to create the package", &create_package_info.scope, &create_package_info.package, colors::cyan_with_underline(&create_package_info.create_url) ); log::warn!("{}", colors::gray("Waiting...")); let _ = open::that_detached(&create_package_info.create_url); let package_api_url = registry::get_package_api_url( registry_api_url, &create_package_info.scope, &create_package_info.package, ); loop { tokio::time::sleep(std::time::Duration::from_secs(3)).await; let response = client.get(package_api_url.parse()?)?.send().await?; if response.status() == 200 { let name = format!( "@{}/{}", create_package_info.scope, create_package_info.package ); log::info!("Package {} created", colors::green(name)); break; } } } Ok(()) } async fn perform_publish( http_client: &HttpClient, mut publish_order_graph: PublishOrderGraph, mut prepared_package_by_name: HashMap<String, Rc<PreparedPublishPackage>>, auth_method: AuthMethod, provenance: bool, ) -> Result<(), AnyError> { let registry_api_url = jsr_api_url(); let registry_url = jsr_url(); let packages = prepared_package_by_name .values() .cloned() .collect::<Vec<_>>(); ensure_scopes_and_packages_exist( http_client, registry_api_url, registry_url, &packages, ) .await?; let mut authorizations = get_auth_headers(http_client, registry_api_url, &packages, auth_method) .await?; assert_eq!(prepared_package_by_name.len(), authorizations.len()); let mut futures: FuturesUnordered<LocalBoxFuture<Result<String, AnyError>>> = Default::default(); // Collect the errors of any packages that failed to publish so that we can // keep publishing the remaining (independent) packages instead of aborting on // the first failure, then report all of them at the end. let mut errors: Vec<AnyError> = Vec::new(); loop { let next_batch = publish_order_graph.next(); for package_name in next_batch { let package = prepared_package_by_name.remove(&package_name).unwrap(); // todo(dsherret): output something that looks better than this even not in debug if log::log_enabled!(log::Level::Debug) { log::debug!("Publishing {}", package.display_name()); for file in &package.tarball.files { log::debug!( " Tarball file {} {}", human_size(file.size as f64), file.specifier ); } } let authorization = authorizations .remove(&( package.scope.clone(), package.package.clone(), package.version.clone(), )) .unwrap(); futures.push( async move { let display_name = package.display_name(); Box::pin(publish_package( http_client, package, registry_api_url, registry_url, &authorization, provenance, )) .await .with_context(|| format!("Failed to publish {}", display_name))?; Ok(package_name) } .boxed_local(), ); } let Some(result) = futures.next().await else { // Done. This `?` is reached with packages still pending only when a // package failed above and its dependents stayed blocked (we don't // `finish_package` a failure). That's safe and won't swallow the // collected errors: `ensure_no_pending` runs the cycle check on the // static package graph, so blocked-but-acyclic dependents return `Ok` and // a genuine circular dependency still surfaces here. publish_order_graph.ensure_no_pending()?; break; }; match result { Ok(package_name) => publish_order_graph.finish_package(&package_name), // Record the failure (preserving its context chain) and keep going so // that the other in-flight and queued packages are still published. We // intentionally don't call `finish_package` here: packages that depend on // the one that failed must not be published against a missing version. Err(err) => errors.push(err), } } if errors.is_empty() { Ok(()) } else { // Any packages still left in `prepared_package_by_name` were never // attempted because they depended (directly or transitively) on a package // that failed to publish, so their dependency version never became // available. Surface them so the skip isn't silent. if !prepared_package_by_name.is_empty() { let mut skipped = prepared_package_by_name .values() .map(|p| p.display_name()) .collect::<Vec<_>>(); skipped.sort(); log::warn!( "{} Skipped publishing {} package(s) that depended on a package that failed to publish:\n{}", colors::yellow("Warning"), skipped.len(), skipped .iter() .map(|name| format!(" {}", name)) .collect::<Vec<_>>() .join("\n"), ); } Err(combine_publish_errors(errors)) } } /// Combines the errors collected while publishing multiple packages into a /// single error, preserving each error's context chain. fn combine_publish_errors(mut errors: Vec<AnyError>) -> AnyError { if errors.len() == 1 { return errors.remove(0); } let mut message = format!("Failed to publish {} packages:", errors.len()); for err in &errors { // `{:#}` renders the full anyhow context chain (e.g. // "Failed to publish @scope/name: <cause>"). message.push_str(&format!("\n\n* {:#}", err)); } deno_core::anyhow::anyhow!(message) } async fn publish_package( http_client: &HttpClient, package: Rc<PreparedPublishPackage>, registry_api_url: &Url, registry_url: &Url, authorization: &str, provenance: bool, ) -> Result<(), AnyError> { log::info!( "{} @{}/{}@{} ...", colors::intense_blue("Publishing"), package.scope, package.package, package.version ); let url = registry::get_package_version_api_url( registry_api_url, &package.scope, &package.package, &package.version, Some(&format!("config=/{}", package.config)), ); let body = deno_fetch::ReqBody::full(package.tarball.bytes.clone()); let response = http_client .post(url.parse()?, body)? .header( http::header::AUTHORIZATION, authorization.parse().map_err(http::Error::from)?, ) .header( http::header::CONTENT_ENCODING, "gzip".parse().map_err(http::Error::from)?, ) .send() .await?; let res = registry::parse_response::<registry::PublishingTask>(response).await; let mut task = match res { Ok(task) => task, Err(mut err) if err.code == "duplicateVersionPublish" => { let task = serde_json::from_value::<registry::PublishingTask>( err.data.get_mut("task").unwrap().take(), ) .unwrap(); if task.status == "success" { log::info!( "{} @{}/{}@{}", colors::yellow("Warning: Skipping, already published"), package.scope, package.package, package.version ); return Ok(()); } log::info!( "{} @{}/{}@{}", colors::yellow("Already uploaded, waiting for publishing"), package.scope, package.package, package.version ); task } // The caller wraps every failure with a "Failed to publish <name>@<version>" // context, so inner messages here avoid repeating that prefix. Err(err) => return Err(err.into()), }; let interval = std::time::Duration::from_secs(2); while task.status != "success" && task.status != "failure" { tokio::time::sleep(interval).await; let resp = http_client .get(format!("{}publish_status/{}", registry_api_url, task.id).parse()?)? .send() .await .with_context(|| { format!( "Failed to get publishing status for @{}/{} at {}", package.scope, package.package, package.version ) })?; task = registry::parse_response::<registry::PublishingTask>(resp) .await .with_context(|| { format!( "Failed to get publishing status for @{}/{} at {}", package.scope, package.package, package.version ) })?; } if let Some(error) = task.error { // The caller adds the "Failed to publish <name>@<version>" context, so only // surface the registry's error message here to avoid a doubled prefix. bail!("{}", error.message); } let enable_provenance = std::env::var("DISABLE_JSR_PROVENANCE").is_err() && (auth::is_gha() && auth::gha_oidc_token().is_some() && provenance); // Enable provenance by default on Github actions with OIDC token if enable_provenance { // Get the version manifest from the registry let meta_url = jsr_url().join(&format!( "@{}/{}/{}_meta.json", package.scope, package.package, package.version ))?; let resp = http_client .get(meta_url.clone())? .send() .await .with_context(|| { format!("Failed to fetch package manifest from {meta_url}") })?; let status = resp.status(); let meta_bytes = resp.collect().await?.to_bytes(); if std::env::var("DISABLE_JSR_MANIFEST_VERIFICATION_FOR_TESTING").is_err() { if !status.is_success() { bail!( "Failed to fetch package manifest from {meta_url}: status {status}\n\n{}", response_body_snippet(&meta_bytes), ); } verify_version_manifest(&meta_bytes, &package).with_context(|| { format!("Failed to verify package manifest from {meta_url}") })?; } let subject = provenance::Subject { name: format!( "pkg:jsr/@{}/{}@{}", package.scope, package.package, package.version ), digest: provenance::SubjectDigest { sha256: faster_hex::hex_string(&sha2::Sha256::digest(&meta_bytes)), }, }; let bundle = Box::pin(provenance::generate_provenance(http_client, vec![subject])) .await?; let log_index = bundle.verification_material.tlog_entries[0].log_index; let transparency_log = format!("https://search.sigstore.dev/?logIndex={log_index}"); // Submit bundle to JSR let provenance_url = format!( "{}scopes/{}/packages/{}/versions/{}/provenance", registry_api_url, package.scope, package.package, package.version ); match submit_provenance_bundle( http_client, &provenance_url, authorization, &bundle, ) .await { Ok(()) => log::info!( "{}", colors::green(format!( "Provenance transparency log available at {transparency_log}" )) ), // The version is already published and immutable at this point, so a // registry-side failure must not turn a successful release into a failed // command. Say plainly what did not happen instead. Err(err) => log::warn!( "{} {:#}\n The package was published, but it will not show a provenance badge.\n The attestation itself was signed and is in the transparency log at {}", colors::yellow( "Warning: the registry did not accept the provenance attestation:" ), err, transparency_log, ), } } log::info!( "{} @{}/{}@{}", colors::green("Successfully published"), package.scope, package.package, package.version ); log::info!( "{}", colors::gray(format!( "Visit {}@{}/{}@{} for details", registry_url, package.scope, package.package, package.version )) ); Ok(()) } fn collect_excluded_module_diagnostics( root_dir: &ModuleSpecifier, graph: &deno_graph::ModuleGraph, current_package_name: &str, all_jsr_packages: &FolderScopedMap<JsrPackageConfig>, publish_paths: &[CollectedPublishPath], diagnostics_collector: &PublishDiagnosticsCollector, ) { let publish_specifiers = publish_paths .iter() .map(|path| &path.specifier) .collect::<HashSet<_>>(); let graph_specifiers = graph .modules() .filter_map(|m| match m { deno_graph::Module::Js(_) | deno_graph::Module::Json(_) | deno_graph::Module::Wasm(_) => Some(m.specifier()), deno_graph::Module::Npm(_) | deno_graph::Module::Node(_) | deno_graph::Module::External(_) => None, }) .filter(|s| s.as_str().starts_with(root_dir.as_str())); let mut outside_specifiers = Vec::new(); let mut had_excluded_specifier = false; for specifier in graph_specifiers { if !publish_specifiers.contains(specifier) { let other_jsr_pkg = all_jsr_packages .get_for_specifier(specifier) .filter(|pkg| pkg.member_dir.dir_url().as_ref() != root_dir); match other_jsr_pkg { Some(other_jsr_pkg) => { outside_specifiers.push((specifier, other_jsr_pkg)); } None => { had_excluded_specifier = true; diagnostics_collector.push(PublishDiagnostic::ExcludedModule { specifier: specifier.clone(), }) } } } } if !had_excluded_specifier { // ensure no path being published references another package // via a relative import for publish_path in publish_paths { let Some(module) = graph.get(&publish_path.specifier) else { continue; }; for (specifier_text, dep) in module.dependencies() { if !deno_path_util::is_relative_specifier(specifier_text) { continue; } let resolutions = dep.maybe_code.ok().into_iter().chain(dep.maybe_type.ok()); let mut maybe_res = resolutions.filter_map(|r| { let pkg = all_jsr_packages.get_for_specifier(&r.specifier)?; if pkg.member_dir.dir_url().as_ref() != root_dir { Some((r, pkg)) } else { None } }); if let Some((outside_res, package)) = maybe_res.next() { diagnostics_collector.push( PublishDiagnostic::RelativePackageImport { // Wasm modules won't have a referrer maybe_referrer: module.source().cloned().map(|source| { RelativePackageImportDiagnosticReferrer { referrer: outside_res.range.clone(), text_info: SourceTextInfo::new(source), } }), from_package_name: current_package_name.to_string(), to_package_name: package.name.clone(), specifier: outside_res.specifier.clone(), }, ); } } } } } #[derive(Deserialize)] struct ManifestEntry { checksum: String, } #[derive(Deserialize)] struct VersionManifest { manifest: HashMap<String, ManifestEntry>, exports: HashMap<String, String>, } /// Submit a signed provenance bundle to the registry, erroring on any response /// that is not a success. /// /// The response used to be discarded entirely, so a registry that rejected /// every attestation looked identical to one that accepted them: publishes kept /// reporting a transparency-log entry and success while no package gained a /// provenance badge. That is how jsr-io/jsr#1474 went unnoticed for a month. async fn submit_provenance_bundle( http_client: &HttpClient, provenance_url: &str, authorization: &str, bundle: &provenance::ProvenanceBundle, ) -> Result<(), AnyError> { let response = http_client .post_json(provenance_url.parse()?, &json!({ "bundle": bundle }))? .header(http::header::AUTHORIZATION, authorization.parse()?) .send() .await?; let status = response.status(); if status.is_success() { return Ok(()); } // Carried into the error so that a report of this warning is traceable in the // registry's own logs, which is where the reason for a rejection lives. let x_deno_ray = response .headers() .get("x-deno-ray") .and_then(|value| value.to_str().ok()) .map(|s| s.to_string()); // The endpoint answers 204 with an empty body on success, so there is nothing // to deserialize; on failure the body is the registry's JSON error, which // `ApiError` renders as "<message> (<code>)". let body = response.collect().await?.to_bytes(); match serde_json::from_slice::<registry::ApiError>(&body) { Ok(mut err) => { err.x_deno_ray = x_deno_ray; Err(err.into()) } Err(_) => bail!("{}: {}", status, response_body_snippet(&body)), } } /// Returns a truncated, lossy UTF-8 rendering of a response body for use in /// error messages, so that a non-JSON response (e.g. an HTML error page) is /// diagnosable instead of surfacing as an opaque deserialization error. fn response_body_snippet(bytes: &[u8]) -> String { const MAX_LEN: usize = 512; let text = String::from_utf8_lossy(bytes); let text = text.trim(); if text.len() > MAX_LEN { let mut end = MAX_LEN; while !text.is_char_boundary(end) { end -= 1; } format!("{}... (truncated)", &text[..end]) } else { text.to_string() } } fn verify_version_manifest( meta_bytes: &[u8], package: &PreparedPublishPackage, ) -> Result<(), AnyError> { let manifest = serde_json::from_slice::<VersionManifest>(meta_bytes) .with_context(|| { format!( "Failed to parse package manifest as JSON. Response body:\n\n{}", response_body_snippet(meta_bytes), ) })?; // Check that nothing was removed from the manifest. if manifest.manifest.len() != package.tarball.files.len() { bail!( "Mismatch in the number of files in the manifest: expected {}, got {}", package.tarball.files.len(), manifest.manifest.len() ); } for (path, entry) in manifest.manifest { // Verify each path with the files in the tarball. let file = package .tarball .files .iter() .find(|f| f.path_str == path.as_str()); if let Some(file) = file { if file.hash != entry.checksum { bail!( "Checksum mismatch for {}: expected {}, got {}", path, entry.checksum, file.hash ); } } else { bail!("File {} not found in the tarball", path); } } for (specifier, expected) in &manifest.exports { let actual = package.exports.get(specifier).ok_or_else(|| { deno_core::anyhow::anyhow!( "Export {} not found in the package", specifier ) })?; if actual != expected { bail!( "Export {} mismatch: expected {}, got {}", specifier, expected, actual ); } } Ok(()) } static SUPPORTED_LICENSE_FILE_NAMES: [&str; 12] = [ "LICENSE", "LICENSE.md", "LICENSE.txt", "LICENCE", "LICENCE.md", "LICENCE.txt", "COPYING", "COPYING.md", "COPYING.txt", "COPYING.LESSER", "COPYING.LESSER.md", "COPYING.LESSER.txt", ]; fn resolve_license_file( pkg_root_dir: &Path, workspace: &Workspace, ) -> Option<PathBuf> { let workspace_root_dir = workspace.root_dir_path(); let mut dirs = Vec::with_capacity(2); dirs.push(pkg_root_dir); if workspace_root_dir != pkg_root_dir { dirs.push(&workspace_root_dir); } for dir in dirs { for file_name in &SUPPORTED_LICENSE_FILE_NAMES { let file_path = dir.join(file_name); if file_path.exists() { return Some(file_path); } } } None } fn has_license_file<'a>( mut specifiers: impl Iterator<Item = &'a ModuleSpecifier>, ) -> bool { let supported_license_files = SUPPORTED_LICENSE_FILE_NAMES .iter() .map(|s| s.to_lowercase()) .collect::<HashSet<_>>(); specifiers.any(|specifier| { specifier .path() .rsplit_once('/') .map(|(_, file)| { supported_license_files.contains(file.to_lowercase().as_str()) }) .unwrap_or(false) }) } fn error_missing_exports_field(deno_json: &ConfigFile) -> Result<(), AnyError> { static SUGGESTED_ENTRYPOINTS: [&str; 4] = ["mod.ts", "mod.js", "index.ts", "index.js"]; let mut suggested_entrypoint = None; for entrypoint in SUGGESTED_ENTRYPOINTS { if deno_json.dir_path().join(entrypoint).exists() { suggested_entrypoint = Some(entrypoint); break; } } let exports_content = format!( r#"{{ "name": "{}", "version": "{}", "exports": "{}" }}"#, deno_json.json.name.as_deref().unwrap_or("@scope/name"), deno_json.json.name.as_deref().unwrap_or("0.0.0"), suggested_entrypoint.unwrap_or("<path_to_entrypoint>") ); bail!( "You did not specify an entrypoint in {}. Add `exports` mapping in the configuration file, eg:\n{}", deno_json.specifier, exports_content ); } #[allow(clippy::print_stderr, reason = "actually want to output")] fn ring_bell() { // ASCII code for the bell character. eprint!("\x07"); } #[cfg(test)] mod tests { use std::collections::HashMap; use deno_ast::ModuleSpecifier; use super::has_license_file; use super::tar::PublishableTarball; use super::tar::PublishableTarballFile; use super::verify_version_manifest; #[test] fn test_verify_version_manifest() { let meta = r#"{ "manifest": { "mod.ts": { "checksum": "abc123" } }, "exports": {} }"#; let meta_bytes = meta.as_bytes(); let package = super::PreparedPublishPackage { scope: "test".to_string(), package: "test".to_string(), version: "1.0.0".to_string(), tarball: PublishableTarball { bytes: vec![].into(), hash: "abc123".to_string(), files: vec![PublishableTarballFile { specifier: "file://mod.ts".try_into().unwrap(), path_str: "mod.ts".to_string(), hash: "abc123".to_string(), size: 0, }], }, config: "deno.json".to_string(), exports: HashMap::new(), }; assert!(verify_version_manifest(meta_bytes, &package).is_ok()); } #[test] fn test_verify_version_manifest_missing() { let meta = r#"{ "manifest": { "mod.ts": {}, }, "exports": {} }"#; let meta_bytes = meta.as_bytes(); let package = super::PreparedPublishPackage { scope: "test".to_string(), package: "test".to_string(), version: "1.0.0".to_string(), tarball: PublishableTarball { bytes: vec![].into(), hash: "abc123".to_string(), files: vec![PublishableTarballFile { specifier: "file://mod.ts".try_into().unwrap(), path_str: "mod.ts".to_string(), hash: "abc123".to_string(), size: 0, }], }, config: "deno.json".to_string(), exports: HashMap::new(), }; assert!(verify_version_manifest(meta_bytes, &package).is_err()); } #[test] fn test_verify_version_manifest_invalid_hash() { let meta = r#"{ "manifest": { "mod.ts": { "checksum": "lol123" }, "exports": {} } }"#; let meta_bytes = meta.as_bytes(); let package = super::PreparedPublishPackage { scope: "test".to_string(), package: "test".to_string(), version: "1.0.0".to_string(), tarball: PublishableTarball { bytes: vec![].into(), hash: "abc123".to_string(), files: vec![PublishableTarballFile { specifier: "file://mod.ts".try_into().unwrap(), path_str: "mod.ts".to_string(), hash: "abc123".to_string(), size: 0, }], }, config: "deno.json".to_string(), exports: HashMap::new(), }; assert!(verify_version_manifest(meta_bytes, &package).is_err()); } #[test] fn test_has_license_files() { fn has_license_file_str(expected: &[&str]) -> bool { let specifiers = expected .iter() .map(|s| ModuleSpecifier::parse(s).unwrap()) .collect::<Vec<_>>(); has_license_file(specifiers.iter()) } assert!(has_license_file_str(&["file:///LICENSE"])); assert!(has_license_file_str(&["file:///license"])); assert!(has_license_file_str(&["file:///LICENSE.txt"])); assert!(has_license_file_str(&["file:///LICENSE.md"])); assert!(has_license_file_str(&["file:///LICENCE"])); assert!(has_license_file_str(&["file:///LICENCE.txt"])); assert!(has_license_file_str(&["file:///LICENCE.md"])); assert!(has_license_file_str(&[ "file:///other", "file:///test/LICENCE.md" ]),); assert!(!has_license_file_str(&[ "file:///other", "file:///test/tLICENSE" ]),); } }