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 1205 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 Target::Cranelift => vec![], 549 }; 550 let src_files = project_files(Utf8Path::new(""))?; 551 let contents_tar_gz = contents_tarball(paths, &src_files, &generated_files)?; 552 let version = "3"; 553 let metadata = metadata_config( 554 &built.root_package.config, 555 &hex_to_otp_app, 556 &src_files, 557 &generated_files, 558 )?; 559 560 // Calculate checksum 561 let mut hasher = sha2::Sha256::new(); 562 hasher.update(version.as_bytes()); 563 hasher.update(metadata.as_bytes()); 564 hasher.update(contents_tar_gz.as_slice()); 565 let checksum = base16::encode_upper(&hasher.finalize()); 566 tracing::info!(checksum = %checksum, "Generated Hex package inner checksum"); 567 568 // Build tarball 569 let mut tarball = Vec::new(); 570 { 571 let mut tarball = tar::Builder::new(&mut tarball); 572 add_to_tar_from_memory(&mut tarball, "VERSION", version.as_bytes())?; 573 add_to_tar_from_memory(&mut tarball, "metadata.config", metadata.as_bytes())?; 574 add_to_tar_from_memory(&mut tarball, "contents.tar.gz", contents_tar_gz.as_slice())?; 575 add_to_tar_from_memory(&mut tarball, "CHECKSUM", checksum.as_bytes())?; 576 tarball.finish().map_err(Error::finish_tar)?; 577 } 578 tracing::info!("Generated package Hex release tarball"); 579 Ok(Tarball { 580 compile_result: built.root_package, 581 cached_modules: built.module_interfaces, 582 data: tarball, 583 src_files_added: src_files, 584 generated_files_added: generated_files, 585 dependencies, 586 }) 587} 588 589fn check_config_for_publishing(config: &PackageConfig) -> Result<()> { 590 // These fields are required to publish a Hex package. Hex will reject 591 // packages without them. 592 if config.description.is_empty() || config.licences.is_empty() { 593 Err(Error::MissingHexPublishFields { 594 description_missing: config.description.is_empty(), 595 licence_missing: config.licences.is_empty(), 596 }) 597 } else { 598 Ok(()) 599 } 600} 601 602fn metadata_config<'a>( 603 config: &'a PackageConfig, 604 hex_to_otp_app: &'a HashMap<EcoString, EcoString>, 605 source_files: &[Utf8PathBuf], 606 generated_files: &[(Utf8PathBuf, String)], 607) -> Result<String> { 608 let repo_url = http::Uri::try_from( 609 config 610 .repository 611 .as_ref() 612 .map(|r| r.url()) 613 .unwrap_or_default(), 614 ) 615 .ok(); 616 let requirements: Result<Vec<ReleaseRequirement<'a>>> = config 617 .dependencies 618 .iter() 619 .map(|(name, requirement)| match requirement { 620 Requirement::Hex { version } => Ok(ReleaseRequirement { 621 name, 622 otp_app: hex_to_otp_app 623 .get(name) 624 .map(EcoString::as_str) 625 .unwrap_or(name.as_str()), 626 requirement: version, 627 }), 628 _ => Err(Error::PublishNonHexDependencies { 629 package: name.to_string(), 630 }), 631 }) 632 .collect(); 633 let metadata = ReleaseMetadata { 634 name: &config.name, 635 version: &config.version, 636 description: &config.description, 637 source_files, 638 generated_files, 639 licenses: &config.licences, 640 links: config 641 .links 642 .iter() 643 .map(|l| (l.title.as_str(), l.href.clone())) 644 .chain(repo_url.into_iter().map(|u| ("Repository", u))) 645 .collect(), 646 requirements: requirements?, 647 build_tools: vec!["gleam"], 648 } 649 .as_erlang(); 650 tracing::info!(contents = ?metadata, "Generated Hex metadata.config"); 651 Ok(metadata) 652} 653 654fn contents_tarball( 655 paths: &ProjectPaths, 656 files: &[Utf8PathBuf], 657 data_files: &[(Utf8PathBuf, String)], 658) -> Result<Vec<u8>, Error> { 659 let mut contents_tar_gz = Vec::new(); 660 { 661 let mut tarball = 662 tar::Builder::new(GzEncoder::new(&mut contents_tar_gz, Compression::default())); 663 for path in files { 664 add_to_tar_from_file_system(&mut tarball, paths, path)?; 665 } 666 for (path, contents) in data_files { 667 add_to_tar_from_memory(&mut tarball, path, contents.as_bytes())?; 668 } 669 tarball.finish().map_err(Error::finish_tar)?; 670 } 671 tracing::info!("Generated contents.tar.gz"); 672 Ok(contents_tar_gz) 673} 674 675fn project_files(base_path: &Utf8Path) -> Result<Vec<Utf8PathBuf>> { 676 let src = base_path.join(Utf8Path::new("src")); 677 let mut files: Vec<Utf8PathBuf> = fs::gleam_files(&src) 678 .chain(fs::native_files(&src)) 679 .collect(); 680 let private = base_path.join(Utf8Path::new("priv")); 681 let mut private_files: Vec<Utf8PathBuf> = fs::priv_directory_files(&private).collect(); 682 files.append(&mut private_files); 683 let mut add = |path| { 684 let path = base_path.join(path); 685 if path.exists() { 686 files.push(path); 687 } 688 }; 689 add("README"); 690 add("README.md"); 691 add("README.txt"); 692 add("gleam.toml"); 693 add("LICENSE"); 694 add("LICENCE"); 695 add("LICENSE.md"); 696 add("LICENCE.md"); 697 add("LICENSE.txt"); 698 add("LICENCE.txt"); 699 add("NOTICE"); 700 add("NOTICE.md"); 701 add("NOTICE.txt"); 702 Ok(files) 703} 704 705// TODO: test 706fn generated_erlang_files( 707 paths: &ProjectPaths, 708 package: &Package, 709) -> Result<Vec<(Utf8PathBuf, String)>> { 710 let mut files = vec![]; 711 712 let dir = paths.build_directory_for_package(Mode::Prod, Target::Erlang, &package.config.name); 713 let ebin = dir.join("ebin"); 714 let build = dir.join(paths::ARTEFACT_DIRECTORY_NAME); 715 let include = dir.join("include"); 716 717 let tar_src = Utf8Path::new("src"); 718 let tar_include = Utf8Path::new("include"); 719 720 // Erlang modules 721 for module in &package.modules { 722 // Do not publish test/ and dev/ code 723 if !module.origin.is_src() { 724 continue; 725 } 726 727 let name = module.compiled_erlang_path(); 728 files.push((tar_src.join(&name), fs::read(build.join(name))?)); 729 } 730 731 // Erlang headers 732 if include.is_dir() { 733 for file in fs::erlang_files(&include) { 734 let name = file.file_name().expect("generated_files include file name"); 735 files.push((tar_include.join(name), fs::read(file)?)); 736 } 737 } 738 739 // src/package.app.src file 740 let app = format!("{}.app", package.config.name); 741 let appsrc = format!("{}.src", app); 742 files.push((tar_src.join(appsrc), fs::read(ebin.join(app))?)); 743 744 Ok(files) 745} 746 747fn add_to_tar_from_memory<P, W>(tarball: &mut tar::Builder<W>, path: P, data: &[u8]) -> Result<()> 748where 749 P: AsRef<Utf8Path>, 750 W: Write, 751{ 752 let path = path.as_ref(); 753 tracing::info!(file=?path, "Adding in memory file to tarball"); 754 755 let mut header = tar::Header::new_gnu(); 756 header.set_mode(0o600); 757 header.set_size(data.len() as u64); 758 header.set_cksum(); 759 tarball 760 .append_data(&mut header, path, data) 761 .map_err(|e| Error::add_tar(path, e)) 762} 763 764fn add_to_tar_from_file_system<P, W>( 765 tarball: &mut tar::Builder<W>, 766 paths: &ProjectPaths, 767 path: P, 768) -> Result<()> 769where 770 P: AsRef<Utf8Path>, 771 W: Write, 772{ 773 let path = path.as_ref(); 774 tracing::info!(file=?&path, "Adding file system file to tarball"); 775 776 let path = fs::canonicalise(path)?; 777 778 let Ok(path) = path.strip_prefix(paths.root()) else { 779 return Err(Error::TarPathOutsideOfProjectRoot { path }); 780 }; 781 782 tarball 783 .append_path(path) 784 .map_err(|e| Error::add_tar(path, e)) 785} 786 787#[test] 788fn add_to_tar_symlink_rejection_test() { 789 let tmp_dir = tempfile::tempdir().unwrap(); 790 let path = std::fs::canonicalize(tmp_dir.path()).unwrap(); 791 let path = Utf8Path::from_path(&path).expect("Non Utf-8 Path"); 792 let paths = ProjectPaths::new(path.join("package")); 793 let mut contents_tar_gz = Vec::new(); 794 let mut tarball = tar::Builder::new(&mut contents_tar_gz); 795 796 // This file is outside the root of the project, so it should 797 // not be possible to add it to the tar archive. 798 let outside_path = path.join("outside.txt"); 799 std::fs::write(&outside_path, "Hello").unwrap(); 800 match add_to_tar_from_file_system(&mut tarball, &paths, &outside_path).unwrap_err() { 801 Error::TarPathOutsideOfProjectRoot { path } => assert_eq!(path, outside_path), 802 other => panic!("Unexpected error {other:?}"), 803 } 804} 805 806#[derive(Debug, Clone)] 807pub struct ReleaseMetadata<'a> { 808 name: &'a str, 809 version: &'a Version, 810 description: &'a str, 811 source_files: &'a [Utf8PathBuf], 812 generated_files: &'a [(Utf8PathBuf, String)], 813 licenses: &'a Vec<SpdxLicense>, 814 links: Vec<(&'a str, http::Uri)>, 815 requirements: Vec<ReleaseRequirement<'a>>, 816 build_tools: Vec<&'a str>, 817 // What should this be? I can't find it in the API anywhere. 818 // extra: (kvlist(string => kvlist(...))) (optional) 819} 820 821impl ReleaseMetadata<'_> { 822 pub fn as_erlang(&self) -> String { 823 fn link(link: &(&str, http::Uri)) -> String { 824 format!( 825 "\n {{<<\"{name}\"/utf8>>, <<\"{url}\"/utf8>>}}", 826 name = link.0, 827 url = link.1 828 ) 829 } 830 fn file(name: impl AsRef<Utf8Path>) -> String { 831 format!("\n <<\"{name}\"/utf8>>", name = name.as_ref()) 832 } 833 834 format!( 835 r#"{{<<"name">>, <<"{name}"/utf8>>}}. 836{{<<"app">>, <<"{name}"/utf8>>}}. 837{{<<"version">>, <<"{version}"/utf8>>}}. 838{{<<"description">>, <<"{description}"/utf8>>}}. 839{{<<"licenses">>, [{licenses}]}}. 840{{<<"build_tools">>, [{build_tools}]}}. 841{{<<"links">>, [{links} 842]}}. 843{{<<"requirements">>, [{requirements} 844]}}. 845{{<<"files">>, [{files} 846]}}. 847"#, 848 name = self.name, 849 version = self.version, 850 description = self.description, 851 files = self 852 .source_files 853 .iter() 854 .chain(self.generated_files.iter().map(|(p, _)| p)) 855 .map(file) 856 .sorted() 857 .join(","), 858 links = self.links.iter().map(link).join(","), 859 licenses = self.licenses.iter().map(|l| quotes(l.as_ref())).join(", "), 860 build_tools = self.build_tools.iter().map(|l| quotes(l)).join(", "), 861 requirements = self 862 .requirements 863 .iter() 864 .map(ReleaseRequirement::as_erlang) 865 .join(",") 866 ) 867 } 868} 869 870#[derive(Debug, Clone)] 871struct ReleaseRequirement<'a> { 872 name: &'a str, 873 // optional: bool, 874 requirement: &'a Range, 875 // Support alternate repositories at a later date. 876 // repository: String, 877 otp_app: &'a str, 878} 879impl ReleaseRequirement<'_> { 880 pub fn as_erlang(&self) -> String { 881 format!( 882 r#" 883 {{<<"{name}"/utf8>>, [ 884 {{<<"app">>, <<"{otp_app}"/utf8>>}}, 885 {{<<"optional">>, false}}, 886 {{<<"requirement">>, <<"{requirement}"/utf8>>}} 887 ]}}"#, 888 name = self.name, 889 otp_app = self.otp_app, 890 requirement = self.requirement, 891 ) 892 } 893} 894 895#[test] 896fn release_metadata_as_erlang() { 897 let licences = vec![ 898 SpdxLicense { 899 licence: "MIT".into(), 900 }, 901 SpdxLicense { 902 licence: "MPL-2.0".into(), 903 }, 904 ]; 905 let version = "1.2.3".try_into().unwrap(); 906 let homepage = "https://gleam.run".parse().unwrap(); 907 let github = "https://github.com/lpil/myapp".parse().unwrap(); 908 let req1 = Range::new("~> 1.2.3 or >= 5.0.0".into()).unwrap(); 909 let req2 = Range::new("~> 1.2".into()).unwrap(); 910 let meta = ReleaseMetadata { 911 name: "myapp", 912 version: &version, 913 description: "description goes here 🌈", 914 source_files: &[ 915 Utf8PathBuf::from("gleam.toml"), 916 Utf8PathBuf::from("src/thingy.gleam"), 917 Utf8PathBuf::from("src/whatever.gleam"), 918 ], 919 generated_files: &[ 920 (Utf8PathBuf::from("src/myapp.app"), "".into()), 921 (Utf8PathBuf::from("src/thingy.erl"), "".into()), 922 (Utf8PathBuf::from("src/whatever.erl"), "".into()), 923 ], 924 licenses: &licences, 925 links: vec![("homepage", homepage), ("github", github)], 926 requirements: vec![ 927 ReleaseRequirement { 928 name: "wibble", 929 otp_app: "wibble", 930 requirement: &req1, 931 }, 932 ReleaseRequirement { 933 name: "wobble", 934 otp_app: "wobble", 935 requirement: &req2, 936 }, 937 ReleaseRequirement { 938 name: "weeble_erl", 939 otp_app: "weeble", 940 requirement: &req2, 941 }, 942 ], 943 build_tools: vec!["gleam", "rebar3"], 944 }; 945 assert_eq!( 946 meta.as_erlang(), 947 r#"{<<"name">>, <<"myapp"/utf8>>}. 948{<<"app">>, <<"myapp"/utf8>>}. 949{<<"version">>, <<"1.2.3"/utf8>>}. 950{<<"description">>, <<"description goes here 🌈"/utf8>>}. 951{<<"licenses">>, [<<"MIT"/utf8>>, <<"MPL-2.0"/utf8>>]}. 952{<<"build_tools">>, [<<"gleam"/utf8>>, <<"rebar3"/utf8>>]}. 953{<<"links">>, [ 954 {<<"homepage"/utf8>>, <<"https://gleam.run/"/utf8>>}, 955 {<<"github"/utf8>>, <<"https://github.com/lpil/myapp"/utf8>>} 956]}. 957{<<"requirements">>, [ 958 {<<"wibble"/utf8>>, [ 959 {<<"app">>, <<"wibble"/utf8>>}, 960 {<<"optional">>, false}, 961 {<<"requirement">>, <<"~> 1.2.3 or >= 5.0.0"/utf8>>} 962 ]}, 963 {<<"wobble"/utf8>>, [ 964 {<<"app">>, <<"wobble"/utf8>>}, 965 {<<"optional">>, false}, 966 {<<"requirement">>, <<"~> 1.2"/utf8>>} 967 ]}, 968 {<<"weeble_erl"/utf8>>, [ 969 {<<"app">>, <<"weeble"/utf8>>}, 970 {<<"optional">>, false}, 971 {<<"requirement">>, <<"~> 1.2"/utf8>>} 972 ]} 973]}. 974{<<"files">>, [ 975 <<"gleam.toml"/utf8>>, 976 <<"src/myapp.app"/utf8>>, 977 <<"src/thingy.erl"/utf8>>, 978 <<"src/thingy.gleam"/utf8>>, 979 <<"src/whatever.erl"/utf8>>, 980 <<"src/whatever.gleam"/utf8>> 981]}. 982"# 983 .to_string() 984 ); 985} 986 987#[test] 988fn prevent_publish_local_dependency() { 989 let config = PackageConfig { 990 dependencies: [("provided".into(), Requirement::path("./path/to/package"))].into(), 991 ..PackageConfig::default() 992 }; 993 assert_eq!( 994 metadata_config(&config, &HashMap::new(), &[], &[]), 995 Err(Error::PublishNonHexDependencies { 996 package: "provided".into() 997 }) 998 ); 999} 1000 1001#[test] 1002fn prevent_publish_git_dependency() { 1003 let config = PackageConfig { 1004 dependencies: [( 1005 "provided".into(), 1006 Requirement::git("https://github.com/gleam-lang/gleam.git", "da6e917"), 1007 )] 1008 .into(), 1009 ..PackageConfig::default() 1010 }; 1011 assert_eq!( 1012 metadata_config(&config, &HashMap::new(), &[], &[]), 1013 Err(Error::PublishNonHexDependencies { 1014 package: "provided".into() 1015 }) 1016 ); 1017} 1018 1019fn quotes(x: &str) -> String { 1020 format!(r#"<<"{x}"/utf8>>"#) 1021} 1022 1023#[test] 1024fn exported_project_files_test() { 1025 let tmp = tempfile::tempdir().unwrap(); 1026 let path = Utf8PathBuf::from_path_buf(tmp.path().join("my_project")).expect("Non Utf8 Path"); 1027 1028 let exported_project_files = &[ 1029 "LICENCE", 1030 "LICENCE.md", 1031 "LICENCE.txt", 1032 "LICENSE", 1033 "LICENSE.md", 1034 "LICENSE.txt", 1035 "NOTICE", 1036 "NOTICE.md", 1037 "NOTICE.txt", 1038 "README", 1039 "README.md", 1040 "README.txt", 1041 "gleam.toml", 1042 "priv/ignored", 1043 "priv/wibble", 1044 "priv/wobble.js", 1045 "src/.hidden/hidden_ffi.erl", 1046 "src/.hidden/hidden_ffi.mjs", 1047 "src/.hidden_ffi.erl", 1048 "src/.hidden_ffi.mjs", 1049 "src/exported.gleam", 1050 "src/exported_ffi.erl", 1051 "src/exported_ffi.ex", 1052 "src/exported_ffi.hrl", 1053 "src/exported_ffi.js", 1054 "src/exported_ffi.mjs", 1055 "src/exported_ffi.ts", 1056 "src/ignored.gleam", 1057 "src/ignored_ffi.erl", 1058 "src/ignored_ffi.mjs", 1059 "src/nested/exported.gleam", 1060 "src/nested/exported_ffi.erl", 1061 "src/nested/exported_ffi.ex", 1062 "src/nested/exported_ffi.hrl", 1063 "src/nested/exported_ffi.js", 1064 "src/nested/exported_ffi.mjs", 1065 "src/nested/exported_ffi.ts", 1066 "src/nested/ignored.gleam", 1067 "src/nested/ignored_ffi.erl", 1068 "src/nested/ignored_ffi.mjs", 1069 ]; 1070 1071 let unexported_project_files = &[ 1072 ".git/", 1073 ".github/workflows/test.yml", 1074 ".gitignore", 1075 "build/", 1076 "ignored.txt", 1077 "src/.hidden/hidden.gleam", // Not a valid Gleam module path 1078 "src/.hidden.gleam", // Not a valid Gleam module name 1079 "src/also-ignored.gleam", // Not a valid Gleam module name 1080 "test/exported_test.gleam", 1081 "test/exported_test_ffi.erl", 1082 "test/exported_test_ffi.ex", 1083 "test/exported_test_ffi.hrl", 1084 "test/exported_test_ffi.js", 1085 "test/exported_test_ffi.mjs", 1086 "test/exported_test_ffi.ts", 1087 "test/ignored_test.gleam", 1088 "test/ignored_test_ffi.erl", 1089 "test/ignored_test_ffi.mjs", 1090 "test/nested/exported_test.gleam", 1091 "test/nested/exported_test_ffi.erl", 1092 "test/nested/exported_test_ffi.ex", 1093 "test/nested/exported_test_ffi.hrl", 1094 "test/nested/exported_test_ffi.js", 1095 "test/nested/exported_test_ffi.mjs", 1096 "test/nested/exported_test_ffi.ts", 1097 "test/nested/ignored.gleam", 1098 "test/nested/ignored_test_ffi.erl", 1099 "test/nested/ignored_test_ffi.mjs", 1100 "dev/exported_test_ffi.erl", 1101 "dev/exported_test_ffi.ex", 1102 "dev/exported_test_ffi.hrl", 1103 "dev/exported_test_ffi.js", 1104 "dev/exported_test_ffi.mjs", 1105 "dev/exported_test_ffi.ts", 1106 "dev/ignored_test.gleam", 1107 "dev/ignored_test_ffi.erl", 1108 "dev/ignored_test_ffi.mjs", 1109 "dev/nested/exported_test.gleam", 1110 "dev/nested/exported_test_ffi.erl", 1111 "dev/nested/exported_test_ffi.ex", 1112 "dev/nested/exported_test_ffi.hrl", 1113 "dev/nested/exported_test_ffi.js", 1114 "dev/nested/exported_test_ffi.mjs", 1115 "dev/nested/exported_test_ffi.ts", 1116 "dev/nested/ignored.gleam", 1117 "dev/nested/ignored_test_ffi.erl", 1118 "dev/nested/ignored_test_ffi.mjs", 1119 "unrelated-file.txt", 1120 ]; 1121 1122 let gitignore = "ignored* 1123src/also-ignored.gleam"; 1124 1125 for &file in exported_project_files 1126 .iter() 1127 .chain(unexported_project_files) 1128 { 1129 if file.ends_with("/") { 1130 fs::mkdir(path.join(file)).unwrap(); 1131 continue; 1132 } 1133 1134 let contents = match file { 1135 ".gitignore" => gitignore, 1136 _ => "", 1137 }; 1138 1139 fs::write(&path.join(file), contents).unwrap(); 1140 } 1141 1142 let mut chosen_exported_files = project_files(&path).unwrap(); 1143 chosen_exported_files.sort_unstable(); 1144 1145 let expected_exported_files = exported_project_files 1146 .iter() 1147 .map(|s| path.join(s)) 1148 .collect_vec(); 1149 1150 assert_eq!(expected_exported_files, chosen_exported_files); 1151} 1152 1153#[test] 1154fn find_git_repo_root_at_same_level() { 1155 let tmp = tempfile::tempdir().unwrap(); 1156 let path = Utf8PathBuf::from_path_buf(tmp.path().join("my_project")).expect("Non Utf8 Path"); 1157 1158 fs::mkdir(path.join(".git")).expect("Create directory"); 1159 1160 let repository_root = fs::get_git_repository_root(path); 1161 assert!( 1162 repository_root 1163 .expect("There should be repo root") 1164 .ends_with("my_project") 1165 ); 1166} 1167 1168#[test] 1169fn find_missing_git_repo_root_at_same_level() { 1170 let tmp = tempfile::tempdir().unwrap(); 1171 let path = Utf8PathBuf::from_path_buf(tmp.path().join("my_project")).expect("Non Utf8 Path"); 1172 1173 let repository_root = fs::get_git_repository_root(path); 1174 assert!(repository_root.is_none()); 1175} 1176 1177#[test] 1178fn find_git_repo_root_at_lower_level() { 1179 let tmp = tempfile::tempdir().unwrap(); 1180 let path = Utf8PathBuf::from_path_buf(tmp.path().join("my_project")).expect("Non Utf8 Path"); 1181 1182 fs::mkdir(path.join(".git")).expect("Create directory"); 1183 1184 let current_path = path.join("subdirectory").join("another_subdirectory"); 1185 fs::mkdir(&current_path).expect("Create directory"); 1186 1187 let repository_root = fs::get_git_repository_root(current_path); 1188 assert!( 1189 repository_root 1190 .expect("There should be repo root") 1191 .ends_with("my_project") 1192 ); 1193} 1194 1195#[test] 1196fn find_missing_git_repo_root_at_lower_level() { 1197 let tmp = tempfile::tempdir().unwrap(); 1198 let path = Utf8PathBuf::from_path_buf(tmp.path().join("my_project")).expect("Non Utf8 Path"); 1199 1200 let current_path = path.join("subdirectory").join("another_subdirectory"); 1201 fs::mkdir(&current_path).expect("Create directory"); 1202 1203 let repository_root = fs::get_git_repository_root(current_path); 1204 assert!(repository_root.is_none()); 1205}