Fork of daniellemaywood.uk/gleam — Wasm codegen work
2

Configure Feed

Select the types of activity you want to include in your feed.

gleam / compiler-cli / src / publish.rs
38 kB 1204 lines
1// SPDX-License-Identifier: Apache-2.0 2// SPDX-FileCopyrightText: 2021 The Gleam contributors 3 4use camino::{Utf8Path, Utf8PathBuf}; 5use ecow::EcoString; 6use flate2::{Compression, write::GzEncoder}; 7use gleam_core::{ 8 Error, Result, 9 analyse::TargetSupport, 10 ast::{CallArg, Statement, TypedExpr, TypedFunction}, 11 build::{Codegen, Compile, Mode, Options, Package, Target}, 12 config::{GleamVersion, PackageConfig, SpdxLicense}, 13 docs::{Dependency, DependencyKind, DocContext}, 14 error::{InvalidReadmeReason, SmallVersion, wrap}, 15 hex, 16 manifest::ManifestPackageSource, 17 paths::{self, ProjectPaths}, 18 requirement::Requirement, 19 type_, 20}; 21use hexpm::version::{Range, Version}; 22use itertools::Itertools; 23use sha2::Digest; 24use std::{collections::HashMap, io::Write}; 25 26use crate::{build, cli, docs, fs, http::HttpClient, new::default_readme}; 27 28const CORE_TEAM_PUBLISH_PASSWORD: &str = "Trans rights are human rights"; 29 30pub fn command(paths: &ProjectPaths, replace: bool, i_am_sure: bool) -> Result<()> { 31 let mut config = crate::config::root_config(paths)?; 32 33 let should_publish = check_for_gleam_prefix(&config)? 34 && check_for_version_zero(&config)? 35 && check_repo_url(&config, i_am_sure)?; 36 37 check_for_invalid_readme(&config, paths)?; 38 39 if !should_publish { 40 println!("Not publishing."); 41 return Ok(()); 42 } 43 44 let Tarball { 45 mut compile_result, 46 cached_modules, 47 data: package_tarball, 48 src_files_added, 49 generated_files_added, 50 dependencies, 51 } = do_build_hex_tarball(paths, &mut config)?; 52 53 check_for_name_squatting(&compile_result)?; 54 check_for_multiple_top_level_modules(&compile_result, i_am_sure)?; 55 check_for_default_main(&compile_result)?; 56 57 // Build HTML documentation 58 let docs_tarball = fs::create_tar_archive(docs::build_documentation( 59 paths, 60 &config, 61 dependencies, 62 &mut compile_result, 63 DocContext::HexPublish, 64 &cached_modules, 65 )?)?; 66 67 // Ask user if this is correct 68 if !generated_files_added.is_empty() { 69 println!("\nGenerated files:"); 70 for file in generated_files_added.iter().sorted() { 71 println!(" - {}", file.0); 72 } 73 } 74 println!("\nSource files:"); 75 for file in src_files_added.iter().sorted() { 76 println!(" - {file}"); 77 } 78 println!("\nName: {}", config.name); 79 println!("Version: {}", config.version); 80 81 let should_publish = i_am_sure || cli::confirm("\nDo you wish to publish this package?")?; 82 if !should_publish { 83 println!("Not publishing."); 84 return Ok(()); 85 } 86 87 let runtime = tokio::runtime::Runtime::new().expect("Unable to start Tokio async runtime"); 88 let http = HttpClient::new(); 89 let hex_config = hexpm::Config::new(); 90 let credentials = crate::hex::HexAuthentication::new(&runtime, &http, hex_config.clone()) 91 .get_or_create_api_credentials()?; 92 let credentials = crate::hex::write_credentials(&credentials)?; 93 cli::print_publishing(&config.name, &config.version); 94 95 runtime.block_on(hex::publish_package( 96 package_tarball, 97 config.version.to_string(), 98 &config.name, 99 &credentials, 100 &hex_config, 101 replace, 102 &http, 103 ))?; 104 105 cli::print_publishing_documentation(); 106 runtime.block_on(hex::publish_documentation( 107 &config.name, 108 &config.version, 109 docs_tarball, 110 &credentials, 111 &hex_config, 112 &http, 113 ))?; 114 cli::print_published("package and documentation"); 115 println!( 116 "\nView your package at https://hex.pm/packages/{}", 117 config.name 118 ); 119 120 // Prompt the user to make a git tag if they have not. 121 let has_repo = config.repository.is_some(); 122 let repository_root = fs::get_git_repository_root(".".into()); 123 if has_repo && let Some(repository_root) = repository_root { 124 let git = repository_root.join(".git"); 125 126 let tag_name = config.tag_for_version(&config.version); 127 let git_tag = git.join("refs").join("tags").join(&tag_name); 128 let tag_exists = git_tag.exists(); 129 130 if !tag_exists { 131 println!( 132 " 133Please push a git tag for this release so source code links in the 134HTML documentation will work: 135 136 git tag {tag_name} 137 git push origin {tag_name} 138" 139 ) 140 } 141 } 142 Ok(()) 143} 144 145fn check_for_invalid_readme(config: &PackageConfig, paths: &ProjectPaths) -> Result<(), Error> { 146 let normalise = |string: String| { 147 string 148 .trim() 149 .replace("\r\n", "") 150 .replace("\n", "") 151 .replace("\t", "") 152 .replace(" ", "") 153 }; 154 155 let project_readme = match fs::read(paths.readme()) { 156 Err(Error::FileIo { 157 err: Some(message), .. 158 }) if message.contains("No such file or directory") => { 159 return Err(Error::CannotPublishWithInvalidReadme { 160 reason: InvalidReadmeReason::Missing, 161 }); 162 } 163 Err(error) => return Err(error), 164 Ok(project_readme) => project_readme, 165 }; 166 167 let normalised_project_readme = normalise(project_readme); 168 if normalised_project_readme.is_empty() { 169 return Err(Error::CannotPublishWithInvalidReadme { 170 reason: InvalidReadmeReason::Empty, 171 }); 172 } 173 174 let default_readme = default_readme(config.name.as_str()); 175 if normalised_project_readme == normalise(default_readme) { 176 return Err(Error::CannotPublishWithInvalidReadme { 177 reason: InvalidReadmeReason::Default, 178 }); 179 } 180 181 Ok(()) 182} 183 184fn check_for_name_squatting(package: &Package) -> Result<(), Error> { 185 if package.modules.len() > 1 { 186 return Ok(()); 187 } 188 189 let Some(module) = package.modules.first() else { 190 return Err(Error::HexPackageSquatting); 191 }; 192 193 if module.dependencies.len() > 1 { 194 return Ok(()); 195 } 196 197 if module.ast.definitions_len() > 2 { 198 return Ok(()); 199 } 200 201 let Some(main) = module 202 .ast 203 .definitions 204 .functions 205 .iter() 206 .find_map(|function| function.main_function()) 207 else { 208 return Ok(()); 209 }; 210 211 if let Some(first) = &main.body.first() 212 && first.is_println() 213 { 214 return Err(Error::HexPackageSquatting); 215 } 216 217 Ok(()) 218} 219 220/// Checks if publishing packages contain default main functions. 221/// Main functions with documentation are considered intentional and allowed. 222fn check_for_default_main(package: &Package) -> Result<(), Error> { 223 let package_name = &package.config.name; 224 225 let has_default_main = package 226 .modules 227 .iter() 228 .flat_map(|module| module.ast.definitions.functions.iter()) 229 .filter_map(|function| function.main_function()) 230 .any(|main| main.documentation.is_none() && is_default_main(main, package_name)); 231 232 if has_default_main { 233 return Err(Error::CannotPublishWithDefaultMain { 234 package_name: package_name.clone(), 235 }); 236 } 237 238 Ok(()) 239} 240 241fn is_default_main(main: &TypedFunction, package_name: &EcoString) -> bool { 242 if main.body.len() != 1 { 243 return false; 244 } 245 246 let Some(Statement::Expression(expression)) = main.body.first() else { 247 return false; 248 }; 249 250 if !expression.is_println() { 251 return false; 252 } 253 254 match expression { 255 TypedExpr::Call { arguments, .. } => { 256 if arguments.len() != 1 { 257 return false; 258 } 259 260 match arguments.first() { 261 Some(CallArg { 262 value: TypedExpr::String { value, .. }, 263 .. 264 }) => { 265 let default_argument = format!("Hello from {}!", package_name); 266 value == &default_argument 267 } 268 _ => false, 269 } 270 } 271 _ => false, 272 } 273} 274 275fn check_for_multiple_top_level_modules(package: &Package, i_am_sure: bool) -> Result<(), Error> { 276 // Collect top-level module names 277 let mut top_level_module_names = package 278 .modules 279 .iter() 280 .filter_map(|module| { 281 // Top-level modules are those that don't contain any path separators 282 if module.name.contains('/') { 283 None 284 } else { 285 Some(module.name.clone()) 286 } 287 }) 288 .collect::<Vec<_>>(); 289 290 // Remove duplicates 291 top_level_module_names.sort_unstable(); 292 top_level_module_names.dedup(); 293 294 // If more than one top-level module name is found, prompt for confirmation 295 if top_level_module_names.len() > 1 { 296 let text = wrap(&format!( 297 "Your package defines multiple top-level modules: {}. 298 299Defining multiple top-level modules can lead to namespace pollution \ 300and potential conflicts for consumers. 301 302To fix this, move all your modules under a single top-level module of your choice. 303 304For example: 305 src/{1}.gleam 306 src/{1}/module1.gleam 307 src/{1}/module2.gleam", 308 top_level_module_names.join(", "), 309 package.config.name 310 )); 311 println!("{text}\n"); 312 313 let should_publish = 314 i_am_sure || cli::confirm("\nDo you wish to continue publishing this package?")?; 315 println!(); 316 317 if !should_publish { 318 println!("Not publishing."); 319 std::process::exit(0); 320 } 321 } 322 323 Ok(()) 324} 325 326fn check_repo_url(config: &PackageConfig, i_am_sure: bool) -> Result<bool, Error> { 327 let Some(repo) = config.repository.as_ref() else { 328 return Ok(true); 329 }; 330 let url = repo.url(); 331 332 let runtime = tokio::runtime::Runtime::new().expect("Unable to start Tokio async runtime"); 333 let response = runtime.block_on(reqwest::get(&url)).map_err(Error::http)?; 334 335 if response.status().is_success() { 336 return Ok(true); 337 } 338 339 println!( 340 "The repository configuration in your `gleam.toml` file does not appear to be 341valid, {} returned status {}", 342 url, 343 response.status() 344 ); 345 let should_publish = i_am_sure || cli::confirm("\nDo you wish to continue?")?; 346 println!(); 347 Ok(should_publish) 348} 349 350/// Ask for confirmation if the package name if a v0.x.x version 351fn check_for_version_zero(config: &PackageConfig) -> Result<bool, Error> { 352 if config.version.major != 0 { 353 return Ok(true); 354 } 355 356 println!( 357 "You are about to publish a release that is below version 1.0.0. 358 359Semantic versioning doesn't apply to version 0.x.x releases, so your 360users will not be protected from breaking changes. This can result 361in a poor user experience where packages can break unexpectedly with 362updates that would normally be safe. 363 364If your package is not ready to be used in production it should not 365be published. 366\n" 367 ); 368 let should_publish = cli::confirm_with_text("I am not using semantic versioning")?; 369 println!(); 370 Ok(should_publish) 371} 372 373/// Ask for confirmation if the package name if `gleam_*` 374fn check_for_gleam_prefix(config: &PackageConfig) -> Result<bool, Error> { 375 if !config.name.starts_with("gleam_") || config.name.starts_with("gleam_community_") { 376 return Ok(true); 377 } 378 379 println!( 380 "You are about to publish a package with a name that starts with 381the prefix `gleam_`, which is preferred for packages maintained by the 382Gleam core team. 383 384Security: do not assume the owner of a package from the name, always check 385the maintainers listed on https://hex.pm/. 386\n", 387 ); 388 let password = cli::ask_password("Please enter the core team password to continue")?; 389 println!(); 390 Ok(password == CORE_TEAM_PUBLISH_PASSWORD) 391} 392 393struct Tarball { 394 compile_result: Package, 395 cached_modules: im::HashMap<EcoString, type_::ModuleInterface>, 396 data: Vec<u8>, 397 src_files_added: Vec<Utf8PathBuf>, 398 generated_files_added: Vec<(Utf8PathBuf, String)>, 399 dependencies: HashMap<EcoString, Dependency>, 400} 401 402pub fn build_hex_tarball(paths: &ProjectPaths, config: &mut PackageConfig) -> Result<Vec<u8>> { 403 let Tarball { data, .. } = do_build_hex_tarball(paths, config)?; 404 Ok(data) 405} 406 407fn do_build_hex_tarball(paths: &ProjectPaths, config: &mut PackageConfig) -> Result<Tarball> { 408 let target = config.target; 409 check_config_for_publishing(config)?; 410 411 // Reset the build directory so we know the state of the project 412 fs::delete_directory(&paths.build_directory_for_target(Mode::Prod, target))?; 413 414 let manifest = build::download_dependencies(paths, cli::Reporter::new())?; 415 let dependencies = manifest 416 .packages 417 .iter() 418 .map(|package| { 419 ( 420 package.name.clone(), 421 Dependency { 422 version: package.version.clone(), 423 kind: match &package.source { 424 ManifestPackageSource::Hex { .. } => DependencyKind::Hex, 425 ManifestPackageSource::Git { .. } => DependencyKind::Git, 426 ManifestPackageSource::Local { .. } => DependencyKind::Path, 427 }, 428 }, 429 ) 430 }) 431 .collect(); 432 433 // Build a lookup from hex package name to OTP application name 434 let hex_to_otp_app = manifest 435 .packages 436 .iter() 437 .filter_map(|package| { 438 package 439 .otp_app 440 .as_ref() 441 .map(|otp_app_name| (package.name.clone(), otp_app_name.clone())) 442 }) 443 .collect::<HashMap<_, _>>(); 444 445 // Build the project to check that it is valid 446 let built = build::main( 447 paths, 448 Options { 449 root_target_support: TargetSupport::Enforced, 450 warnings_as_errors: false, 451 mode: Mode::Prod, 452 target: Some(target), 453 codegen: Codegen::All, 454 compile: Compile::All, 455 no_print_progress: false, 456 }, 457 manifest, 458 )?; 459 460 let minimum_required_version = built.minimum_required_version(); 461 match &config.gleam_version { 462 // If the package has no explicit `gleam` version in its `gleam.toml` 463 // then we want to add the automatically inferred one so we know it's 464 // correct and folks getting the package from Hex won't have unpleasant 465 // surprises if the author forgot to manualy write it down. 466 None => { 467 // If we're automatically adding the minimum required version 468 // constraint we want it to at least be `>= 1.0.0`, even if the 469 // inferred lower bound could be lower. 470 let minimum_required_version = 471 std::cmp::max(minimum_required_version, Version::new(1, 0, 0)); 472 let inferred_version_range = pubgrub::Range::higher_than(minimum_required_version); 473 config.gleam_version = Some(GleamVersion::from_pubgrub(inferred_version_range)); 474 } 475 // Otherwise we need to check that the annotated version range is 476 // correct and includes the minimum required version. 477 Some(gleam_version) => { 478 if let Some(lowest_allowed_version) = gleam_version.lowest_version() 479 && lowest_allowed_version < minimum_required_version 480 { 481 return Err(Error::CannotPublishWrongVersion { 482 minimum_required_version: SmallVersion::from_hexpm(minimum_required_version), 483 wrongfully_allowed_version: SmallVersion::from_hexpm(lowest_allowed_version), 484 }); 485 } 486 } 487 } 488 489 // If any of the modules in the package contain a todo or an echo then 490 // refuse to publish as the package is not yet finished. 491 let mut modules_containing_todo = vec![]; 492 let mut modules_containing_echo = vec![]; 493 494 for module in built.root_package.modules.iter() { 495 if module.ast.type_info.contains_todo() { 496 modules_containing_todo.push(module.name.clone()); 497 } else if module.ast.type_info.contains_echo { 498 modules_containing_echo.push(module.name.clone()); 499 } 500 } 501 502 if !modules_containing_todo.is_empty() { 503 return Err(Error::CannotPublishTodo { 504 unfinished: modules_containing_todo, 505 }); 506 } 507 508 if !modules_containing_echo.is_empty() { 509 return Err(Error::CannotPublishEcho { 510 unfinished: modules_containing_echo, 511 }); 512 } 513 514 // empty_modules is a list of modules that do not export any values or types. 515 // We do not allow publishing packages that contain empty modules. 516 let empty_modules: Vec<_> = built 517 .root_package 518 .modules 519 .iter() 520 .filter(|module| { 521 built 522 .module_interfaces 523 .get(&module.name) 524 .map(|interface| { 525 // Check if the module exports any values or types 526 interface.values.is_empty() && interface.types.is_empty() 527 }) 528 .unwrap_or(false) 529 }) 530 .map(|module| module.name.clone()) 531 .collect(); 532 533 if !empty_modules.is_empty() { 534 return Err(Error::CannotPublishEmptyModules { 535 unfinished: empty_modules, 536 }); 537 } 538 539 // TODO: If any of the modules in the package contain a leaked internal type then 540 // refuse to publish as the package is not yet finished. 541 // We need to move aliases in to the type system first. 542 // context: https://discord.com/channels/768594524158427167/768594524158427170/1227250677734969386 543 544 // Collect all the files we want to include in the tarball 545 let generated_files = match target { 546 Target::Erlang => generated_erlang_files(paths, &built.root_package)?, 547 Target::JavaScript => vec![], 548 }; 549 let src_files = project_files(Utf8Path::new(""))?; 550 let contents_tar_gz = contents_tarball(paths, &src_files, &generated_files)?; 551 let version = "3"; 552 let metadata = metadata_config( 553 &built.root_package.config, 554 &hex_to_otp_app, 555 &src_files, 556 &generated_files, 557 )?; 558 559 // Calculate checksum 560 let mut hasher = sha2::Sha256::new(); 561 hasher.update(version.as_bytes()); 562 hasher.update(metadata.as_bytes()); 563 hasher.update(contents_tar_gz.as_slice()); 564 let checksum = base16::encode_upper(&hasher.finalize()); 565 tracing::info!(checksum = %checksum, "Generated Hex package inner checksum"); 566 567 // Build tarball 568 let mut tarball = Vec::new(); 569 { 570 let mut tarball = tar::Builder::new(&mut tarball); 571 add_to_tar_from_memory(&mut tarball, "VERSION", version.as_bytes())?; 572 add_to_tar_from_memory(&mut tarball, "metadata.config", metadata.as_bytes())?; 573 add_to_tar_from_memory(&mut tarball, "contents.tar.gz", contents_tar_gz.as_slice())?; 574 add_to_tar_from_memory(&mut tarball, "CHECKSUM", checksum.as_bytes())?; 575 tarball.finish().map_err(Error::finish_tar)?; 576 } 577 tracing::info!("Generated package Hex release tarball"); 578 Ok(Tarball { 579 compile_result: built.root_package, 580 cached_modules: built.module_interfaces, 581 data: tarball, 582 src_files_added: src_files, 583 generated_files_added: generated_files, 584 dependencies, 585 }) 586} 587 588fn check_config_for_publishing(config: &PackageConfig) -> Result<()> { 589 // These fields are required to publish a Hex package. Hex will reject 590 // packages without them. 591 if config.description.is_empty() || config.licences.is_empty() { 592 Err(Error::MissingHexPublishFields { 593 description_missing: config.description.is_empty(), 594 licence_missing: config.licences.is_empty(), 595 }) 596 } else { 597 Ok(()) 598 } 599} 600 601fn metadata_config<'a>( 602 config: &'a PackageConfig, 603 hex_to_otp_app: &'a HashMap<EcoString, EcoString>, 604 source_files: &[Utf8PathBuf], 605 generated_files: &[(Utf8PathBuf, String)], 606) -> Result<String> { 607 let repo_url = http::Uri::try_from( 608 config 609 .repository 610 .as_ref() 611 .map(|r| r.url()) 612 .unwrap_or_default(), 613 ) 614 .ok(); 615 let requirements: Result<Vec<ReleaseRequirement<'a>>> = config 616 .dependencies 617 .iter() 618 .map(|(name, requirement)| match requirement { 619 Requirement::Hex { version } => Ok(ReleaseRequirement { 620 name, 621 otp_app: hex_to_otp_app 622 .get(name) 623 .map(EcoString::as_str) 624 .unwrap_or(name.as_str()), 625 requirement: version, 626 }), 627 _ => Err(Error::PublishNonHexDependencies { 628 package: name.to_string(), 629 }), 630 }) 631 .collect(); 632 let metadata = ReleaseMetadata { 633 name: &config.name, 634 version: &config.version, 635 description: &config.description, 636 source_files, 637 generated_files, 638 licenses: &config.licences, 639 links: config 640 .links 641 .iter() 642 .map(|l| (l.title.as_str(), l.href.clone())) 643 .chain(repo_url.into_iter().map(|u| ("Repository", u))) 644 .collect(), 645 requirements: requirements?, 646 build_tools: vec!["gleam"], 647 } 648 .as_erlang(); 649 tracing::info!(contents = ?metadata, "Generated Hex metadata.config"); 650 Ok(metadata) 651} 652 653fn contents_tarball( 654 paths: &ProjectPaths, 655 files: &[Utf8PathBuf], 656 data_files: &[(Utf8PathBuf, String)], 657) -> Result<Vec<u8>, Error> { 658 let mut contents_tar_gz = Vec::new(); 659 { 660 let mut tarball = 661 tar::Builder::new(GzEncoder::new(&mut contents_tar_gz, Compression::default())); 662 for path in files { 663 add_to_tar_from_file_system(&mut tarball, paths, path)?; 664 } 665 for (path, contents) in data_files { 666 add_to_tar_from_memory(&mut tarball, path, contents.as_bytes())?; 667 } 668 tarball.finish().map_err(Error::finish_tar)?; 669 } 670 tracing::info!("Generated contents.tar.gz"); 671 Ok(contents_tar_gz) 672} 673 674fn project_files(base_path: &Utf8Path) -> Result<Vec<Utf8PathBuf>> { 675 let src = base_path.join(Utf8Path::new("src")); 676 let mut files: Vec<Utf8PathBuf> = fs::gleam_files(&src) 677 .chain(fs::native_files(&src)) 678 .collect(); 679 let private = base_path.join(Utf8Path::new("priv")); 680 let mut private_files: Vec<Utf8PathBuf> = fs::priv_directory_files(&private).collect(); 681 files.append(&mut private_files); 682 let mut add = |path| { 683 let path = base_path.join(path); 684 if path.exists() { 685 files.push(path); 686 } 687 }; 688 add("README"); 689 add("README.md"); 690 add("README.txt"); 691 add("gleam.toml"); 692 add("LICENSE"); 693 add("LICENCE"); 694 add("LICENSE.md"); 695 add("LICENCE.md"); 696 add("LICENSE.txt"); 697 add("LICENCE.txt"); 698 add("NOTICE"); 699 add("NOTICE.md"); 700 add("NOTICE.txt"); 701 Ok(files) 702} 703 704// TODO: test 705fn generated_erlang_files( 706 paths: &ProjectPaths, 707 package: &Package, 708) -> Result<Vec<(Utf8PathBuf, String)>> { 709 let mut files = vec![]; 710 711 let dir = paths.build_directory_for_package(Mode::Prod, Target::Erlang, &package.config.name); 712 let ebin = dir.join("ebin"); 713 let build = dir.join(paths::ARTEFACT_DIRECTORY_NAME); 714 let include = dir.join("include"); 715 716 let tar_src = Utf8Path::new("src"); 717 let tar_include = Utf8Path::new("include"); 718 719 // Erlang modules 720 for module in &package.modules { 721 // Do not publish test/ and dev/ code 722 if !module.origin.is_src() { 723 continue; 724 } 725 726 let name = module.compiled_erlang_path(); 727 files.push((tar_src.join(&name), fs::read(build.join(name))?)); 728 } 729 730 // Erlang headers 731 if include.is_dir() { 732 for file in fs::erlang_files(&include) { 733 let name = file.file_name().expect("generated_files include file name"); 734 files.push((tar_include.join(name), fs::read(file)?)); 735 } 736 } 737 738 // src/package.app.src file 739 let app = format!("{}.app", package.config.name); 740 let appsrc = format!("{}.src", app); 741 files.push((tar_src.join(appsrc), fs::read(ebin.join(app))?)); 742 743 Ok(files) 744} 745 746fn add_to_tar_from_memory<P, W>(tarball: &mut tar::Builder<W>, path: P, data: &[u8]) -> Result<()> 747where 748 P: AsRef<Utf8Path>, 749 W: Write, 750{ 751 let path = path.as_ref(); 752 tracing::info!(file=?path, "Adding in memory file to tarball"); 753 754 let mut header = tar::Header::new_gnu(); 755 header.set_mode(0o600); 756 header.set_size(data.len() as u64); 757 header.set_cksum(); 758 tarball 759 .append_data(&mut header, path, data) 760 .map_err(|e| Error::add_tar(path, e)) 761} 762 763fn add_to_tar_from_file_system<P, W>( 764 tarball: &mut tar::Builder<W>, 765 paths: &ProjectPaths, 766 path: P, 767) -> Result<()> 768where 769 P: AsRef<Utf8Path>, 770 W: Write, 771{ 772 let path = path.as_ref(); 773 tracing::info!(file=?&path, "Adding file system file to tarball"); 774 775 let path = fs::canonicalise(path)?; 776 777 let Ok(path) = path.strip_prefix(paths.root()) else { 778 return Err(Error::TarPathOutsideOfProjectRoot { path }); 779 }; 780 781 tarball 782 .append_path(path) 783 .map_err(|e| Error::add_tar(path, e)) 784} 785 786#[test] 787fn add_to_tar_symlink_rejection_test() { 788 let tmp_dir = tempfile::tempdir().unwrap(); 789 let path = std::fs::canonicalize(tmp_dir.path()).unwrap(); 790 let path = Utf8Path::from_path(&path).expect("Non Utf-8 Path"); 791 let paths = ProjectPaths::new(path.join("package")); 792 let mut contents_tar_gz = Vec::new(); 793 let mut tarball = tar::Builder::new(&mut contents_tar_gz); 794 795 // This file is outside the root of the project, so it should 796 // not be possible to add it to the tar archive. 797 let outside_path = path.join("outside.txt"); 798 std::fs::write(&outside_path, "Hello").unwrap(); 799 match add_to_tar_from_file_system(&mut tarball, &paths, &outside_path).unwrap_err() { 800 Error::TarPathOutsideOfProjectRoot { path } => assert_eq!(path, outside_path), 801 other => panic!("Unexpected error {other:?}"), 802 } 803} 804 805#[derive(Debug, Clone)] 806pub struct ReleaseMetadata<'a> { 807 name: &'a str, 808 version: &'a Version, 809 description: &'a str, 810 source_files: &'a [Utf8PathBuf], 811 generated_files: &'a [(Utf8PathBuf, String)], 812 licenses: &'a Vec<SpdxLicense>, 813 links: Vec<(&'a str, http::Uri)>, 814 requirements: Vec<ReleaseRequirement<'a>>, 815 build_tools: Vec<&'a str>, 816 // What should this be? I can't find it in the API anywhere. 817 // extra: (kvlist(string => kvlist(...))) (optional) 818} 819 820impl ReleaseMetadata<'_> { 821 pub fn as_erlang(&self) -> String { 822 fn link(link: &(&str, http::Uri)) -> String { 823 format!( 824 "\n {{<<\"{name}\"/utf8>>, <<\"{url}\"/utf8>>}}", 825 name = link.0, 826 url = link.1 827 ) 828 } 829 fn file(name: impl AsRef<Utf8Path>) -> String { 830 format!("\n <<\"{name}\"/utf8>>", name = name.as_ref()) 831 } 832 833 format!( 834 r#"{{<<"name">>, <<"{name}"/utf8>>}}. 835{{<<"app">>, <<"{name}"/utf8>>}}. 836{{<<"version">>, <<"{version}"/utf8>>}}. 837{{<<"description">>, <<"{description}"/utf8>>}}. 838{{<<"licenses">>, [{licenses}]}}. 839{{<<"build_tools">>, [{build_tools}]}}. 840{{<<"links">>, [{links} 841]}}. 842{{<<"requirements">>, [{requirements} 843]}}. 844{{<<"files">>, [{files} 845]}}. 846"#, 847 name = self.name, 848 version = self.version, 849 description = self.description, 850 files = self 851 .source_files 852 .iter() 853 .chain(self.generated_files.iter().map(|(p, _)| p)) 854 .map(file) 855 .sorted() 856 .join(","), 857 links = self.links.iter().map(link).join(","), 858 licenses = self.licenses.iter().map(|l| quotes(l.as_ref())).join(", "), 859 build_tools = self.build_tools.iter().map(|l| quotes(l)).join(", "), 860 requirements = self 861 .requirements 862 .iter() 863 .map(ReleaseRequirement::as_erlang) 864 .join(",") 865 ) 866 } 867} 868 869#[derive(Debug, Clone)] 870struct ReleaseRequirement<'a> { 871 name: &'a str, 872 // optional: bool, 873 requirement: &'a Range, 874 // Support alternate repositories at a later date. 875 // repository: String, 876 otp_app: &'a str, 877} 878impl ReleaseRequirement<'_> { 879 pub fn as_erlang(&self) -> String { 880 format!( 881 r#" 882 {{<<"{name}"/utf8>>, [ 883 {{<<"app">>, <<"{otp_app}"/utf8>>}}, 884 {{<<"optional">>, false}}, 885 {{<<"requirement">>, <<"{requirement}"/utf8>>}} 886 ]}}"#, 887 name = self.name, 888 otp_app = self.otp_app, 889 requirement = self.requirement, 890 ) 891 } 892} 893 894#[test] 895fn release_metadata_as_erlang() { 896 let licences = vec![ 897 SpdxLicense { 898 licence: "MIT".into(), 899 }, 900 SpdxLicense { 901 licence: "MPL-2.0".into(), 902 }, 903 ]; 904 let version = "1.2.3".try_into().unwrap(); 905 let homepage = "https://gleam.run".parse().unwrap(); 906 let github = "https://github.com/lpil/myapp".parse().unwrap(); 907 let req1 = Range::new("~> 1.2.3 or >= 5.0.0".into()).unwrap(); 908 let req2 = Range::new("~> 1.2".into()).unwrap(); 909 let meta = ReleaseMetadata { 910 name: "myapp", 911 version: &version, 912 description: "description goes here 🌈", 913 source_files: &[ 914 Utf8PathBuf::from("gleam.toml"), 915 Utf8PathBuf::from("src/thingy.gleam"), 916 Utf8PathBuf::from("src/whatever.gleam"), 917 ], 918 generated_files: &[ 919 (Utf8PathBuf::from("src/myapp.app"), "".into()), 920 (Utf8PathBuf::from("src/thingy.erl"), "".into()), 921 (Utf8PathBuf::from("src/whatever.erl"), "".into()), 922 ], 923 licenses: &licences, 924 links: vec![("homepage", homepage), ("github", github)], 925 requirements: vec![ 926 ReleaseRequirement { 927 name: "wibble", 928 otp_app: "wibble", 929 requirement: &req1, 930 }, 931 ReleaseRequirement { 932 name: "wobble", 933 otp_app: "wobble", 934 requirement: &req2, 935 }, 936 ReleaseRequirement { 937 name: "weeble_erl", 938 otp_app: "weeble", 939 requirement: &req2, 940 }, 941 ], 942 build_tools: vec!["gleam", "rebar3"], 943 }; 944 assert_eq!( 945 meta.as_erlang(), 946 r#"{<<"name">>, <<"myapp"/utf8>>}. 947{<<"app">>, <<"myapp"/utf8>>}. 948{<<"version">>, <<"1.2.3"/utf8>>}. 949{<<"description">>, <<"description goes here 🌈"/utf8>>}. 950{<<"licenses">>, [<<"MIT"/utf8>>, <<"MPL-2.0"/utf8>>]}. 951{<<"build_tools">>, [<<"gleam"/utf8>>, <<"rebar3"/utf8>>]}. 952{<<"links">>, [ 953 {<<"homepage"/utf8>>, <<"https://gleam.run/"/utf8>>}, 954 {<<"github"/utf8>>, <<"https://github.com/lpil/myapp"/utf8>>} 955]}. 956{<<"requirements">>, [ 957 {<<"wibble"/utf8>>, [ 958 {<<"app">>, <<"wibble"/utf8>>}, 959 {<<"optional">>, false}, 960 {<<"requirement">>, <<"~> 1.2.3 or >= 5.0.0"/utf8>>} 961 ]}, 962 {<<"wobble"/utf8>>, [ 963 {<<"app">>, <<"wobble"/utf8>>}, 964 {<<"optional">>, false}, 965 {<<"requirement">>, <<"~> 1.2"/utf8>>} 966 ]}, 967 {<<"weeble_erl"/utf8>>, [ 968 {<<"app">>, <<"weeble"/utf8>>}, 969 {<<"optional">>, false}, 970 {<<"requirement">>, <<"~> 1.2"/utf8>>} 971 ]} 972]}. 973{<<"files">>, [ 974 <<"gleam.toml"/utf8>>, 975 <<"src/myapp.app"/utf8>>, 976 <<"src/thingy.erl"/utf8>>, 977 <<"src/thingy.gleam"/utf8>>, 978 <<"src/whatever.erl"/utf8>>, 979 <<"src/whatever.gleam"/utf8>> 980]}. 981"# 982 .to_string() 983 ); 984} 985 986#[test] 987fn prevent_publish_local_dependency() { 988 let config = PackageConfig { 989 dependencies: [("provided".into(), Requirement::path("./path/to/package"))].into(), 990 ..PackageConfig::default() 991 }; 992 assert_eq!( 993 metadata_config(&config, &HashMap::new(), &[], &[]), 994 Err(Error::PublishNonHexDependencies { 995 package: "provided".into() 996 }) 997 ); 998} 999 1000#[test] 1001fn prevent_publish_git_dependency() { 1002 let config = PackageConfig { 1003 dependencies: [( 1004 "provided".into(), 1005 Requirement::git("https://github.com/gleam-lang/gleam.git", "da6e917"), 1006 )] 1007 .into(), 1008 ..PackageConfig::default() 1009 }; 1010 assert_eq!( 1011 metadata_config(&config, &HashMap::new(), &[], &[]), 1012 Err(Error::PublishNonHexDependencies { 1013 package: "provided".into() 1014 }) 1015 ); 1016} 1017 1018fn quotes(x: &str) -> String { 1019 format!(r#"<<"{x}"/utf8>>"#) 1020} 1021 1022#[test] 1023fn exported_project_files_test() { 1024 let tmp = tempfile::tempdir().unwrap(); 1025 let path = Utf8PathBuf::from_path_buf(tmp.path().join("my_project")).expect("Non Utf8 Path"); 1026 1027 let exported_project_files = &[ 1028 "LICENCE", 1029 "LICENCE.md", 1030 "LICENCE.txt", 1031 "LICENSE", 1032 "LICENSE.md", 1033 "LICENSE.txt", 1034 "NOTICE", 1035 "NOTICE.md", 1036 "NOTICE.txt", 1037 "README", 1038 "README.md", 1039 "README.txt", 1040 "gleam.toml", 1041 "priv/ignored", 1042 "priv/wibble", 1043 "priv/wobble.js", 1044 "src/.hidden/hidden_ffi.erl", 1045 "src/.hidden/hidden_ffi.mjs", 1046 "src/.hidden_ffi.erl", 1047 "src/.hidden_ffi.mjs", 1048 "src/exported.gleam", 1049 "src/exported_ffi.erl", 1050 "src/exported_ffi.ex", 1051 "src/exported_ffi.hrl", 1052 "src/exported_ffi.js", 1053 "src/exported_ffi.mjs", 1054 "src/exported_ffi.ts", 1055 "src/ignored.gleam", 1056 "src/ignored_ffi.erl", 1057 "src/ignored_ffi.mjs", 1058 "src/nested/exported.gleam", 1059 "src/nested/exported_ffi.erl", 1060 "src/nested/exported_ffi.ex", 1061 "src/nested/exported_ffi.hrl", 1062 "src/nested/exported_ffi.js", 1063 "src/nested/exported_ffi.mjs", 1064 "src/nested/exported_ffi.ts", 1065 "src/nested/ignored.gleam", 1066 "src/nested/ignored_ffi.erl", 1067 "src/nested/ignored_ffi.mjs", 1068 ]; 1069 1070 let unexported_project_files = &[ 1071 ".git/", 1072 ".github/workflows/test.yml", 1073 ".gitignore", 1074 "build/", 1075 "ignored.txt", 1076 "src/.hidden/hidden.gleam", // Not a valid Gleam module path 1077 "src/.hidden.gleam", // Not a valid Gleam module name 1078 "src/also-ignored.gleam", // Not a valid Gleam module name 1079 "test/exported_test.gleam", 1080 "test/exported_test_ffi.erl", 1081 "test/exported_test_ffi.ex", 1082 "test/exported_test_ffi.hrl", 1083 "test/exported_test_ffi.js", 1084 "test/exported_test_ffi.mjs", 1085 "test/exported_test_ffi.ts", 1086 "test/ignored_test.gleam", 1087 "test/ignored_test_ffi.erl", 1088 "test/ignored_test_ffi.mjs", 1089 "test/nested/exported_test.gleam", 1090 "test/nested/exported_test_ffi.erl", 1091 "test/nested/exported_test_ffi.ex", 1092 "test/nested/exported_test_ffi.hrl", 1093 "test/nested/exported_test_ffi.js", 1094 "test/nested/exported_test_ffi.mjs", 1095 "test/nested/exported_test_ffi.ts", 1096 "test/nested/ignored.gleam", 1097 "test/nested/ignored_test_ffi.erl", 1098 "test/nested/ignored_test_ffi.mjs", 1099 "dev/exported_test_ffi.erl", 1100 "dev/exported_test_ffi.ex", 1101 "dev/exported_test_ffi.hrl", 1102 "dev/exported_test_ffi.js", 1103 "dev/exported_test_ffi.mjs", 1104 "dev/exported_test_ffi.ts", 1105 "dev/ignored_test.gleam", 1106 "dev/ignored_test_ffi.erl", 1107 "dev/ignored_test_ffi.mjs", 1108 "dev/nested/exported_test.gleam", 1109 "dev/nested/exported_test_ffi.erl", 1110 "dev/nested/exported_test_ffi.ex", 1111 "dev/nested/exported_test_ffi.hrl", 1112 "dev/nested/exported_test_ffi.js", 1113 "dev/nested/exported_test_ffi.mjs", 1114 "dev/nested/exported_test_ffi.ts", 1115 "dev/nested/ignored.gleam", 1116 "dev/nested/ignored_test_ffi.erl", 1117 "dev/nested/ignored_test_ffi.mjs", 1118 "unrelated-file.txt", 1119 ]; 1120 1121 let gitignore = "ignored* 1122src/also-ignored.gleam"; 1123 1124 for &file in exported_project_files 1125 .iter() 1126 .chain(unexported_project_files) 1127 { 1128 if file.ends_with("/") { 1129 fs::mkdir(path.join(file)).unwrap(); 1130 continue; 1131 } 1132 1133 let contents = match file { 1134 ".gitignore" => gitignore, 1135 _ => "", 1136 }; 1137 1138 fs::write(&path.join(file), contents).unwrap(); 1139 } 1140 1141 let mut chosen_exported_files = project_files(&path).unwrap(); 1142 chosen_exported_files.sort_unstable(); 1143 1144 let expected_exported_files = exported_project_files 1145 .iter() 1146 .map(|s| path.join(s)) 1147 .collect_vec(); 1148 1149 assert_eq!(expected_exported_files, chosen_exported_files); 1150} 1151 1152#[test] 1153fn find_git_repo_root_at_same_level() { 1154 let tmp = tempfile::tempdir().unwrap(); 1155 let path = Utf8PathBuf::from_path_buf(tmp.path().join("my_project")).expect("Non Utf8 Path"); 1156 1157 fs::mkdir(path.join(".git")).expect("Create directory"); 1158 1159 let repository_root = fs::get_git_repository_root(path); 1160 assert!( 1161 repository_root 1162 .expect("There should be repo root") 1163 .ends_with("my_project") 1164 ); 1165} 1166 1167#[test] 1168fn find_missing_git_repo_root_at_same_level() { 1169 let tmp = tempfile::tempdir().unwrap(); 1170 let path = Utf8PathBuf::from_path_buf(tmp.path().join("my_project")).expect("Non Utf8 Path"); 1171 1172 let repository_root = fs::get_git_repository_root(path); 1173 assert!(repository_root.is_none()); 1174} 1175 1176#[test] 1177fn find_git_repo_root_at_lower_level() { 1178 let tmp = tempfile::tempdir().unwrap(); 1179 let path = Utf8PathBuf::from_path_buf(tmp.path().join("my_project")).expect("Non Utf8 Path"); 1180 1181 fs::mkdir(path.join(".git")).expect("Create directory"); 1182 1183 let current_path = path.join("subdirectory").join("another_subdirectory"); 1184 fs::mkdir(&current_path).expect("Create directory"); 1185 1186 let repository_root = fs::get_git_repository_root(current_path); 1187 assert!( 1188 repository_root 1189 .expect("There should be repo root") 1190 .ends_with("my_project") 1191 ); 1192} 1193 1194#[test] 1195fn find_missing_git_repo_root_at_lower_level() { 1196 let tmp = tempfile::tempdir().unwrap(); 1197 let path = Utf8PathBuf::from_path_buf(tmp.path().join("my_project")).expect("Non Utf8 Path"); 1198 1199 let current_path = path.join("subdirectory").join("another_subdirectory"); 1200 fs::mkdir(&current_path).expect("Create directory"); 1201 1202 let repository_root = fs::get_git_repository_root(current_path); 1203 assert!(repository_root.is_none()); 1204}