Fork of daniellemaywood.uk/gleam — Wasm codegen work
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(¤t_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(¤t_path).expect("Create directory");
1201
1202 let repository_root = fs::get_git_repository_root(current_path);
1203 assert!(repository_root.is_none());
1204}