File Coverage

blib/lib/App/DocKnot/Dist.pm
Criterion Covered Total %
statement 147 150 98.0
branch 31 40 77.5
condition 6 6 100.0
subroutine 27 27 100.0
pod 4 4 100.0
total 215 227 94.7


line stmt bran cond sub pod time code
1             # Generate a distribution tarball for a package.
2             #
3             # This is the implementation of the docknot dist command, which determines and
4             # runs the commands necessary to build a distribution tarball for a given
5             # package.
6             #
7             # SPDX-License-Identifier: MIT
8              
9             ##############################################################################
10             # Modules and declarations
11             ##############################################################################
12              
13             package App::DocKnot::Dist 3.02;
14              
15 4     4   138499 use 5.024;
  4         29  
16 4     4   24 use autodie;
  4         10  
  4         43  
17 4     4   18288 use warnings;
  4         10  
  4         136  
18              
19 4     4   1851 use App::DocKnot::Config;
  4         11  
  4         118  
20 4     4   2407 use Archive::Tar ();
  4         302510  
  4         137  
21 4     4   32 use Carp qw(croak);
  4         6  
  4         182  
22 4     4   21 use Cwd qw(getcwd);
  4         8  
  4         138  
23 4     4   1347 use File::Copy qw(move);
  4         5693  
  4         221  
24 4     4   26 use File::Find qw(find);
  4         7  
  4         243  
25 4     4   24 use File::Path qw(remove_tree);
  4         6  
  4         179  
26 4     4   2555 use IPC::Run qw(run);
  4         79190  
  4         201  
27 4     4   1405 use IPC::System::Simple qw(systemx);
  4         14874  
  4         229  
28 4     4   25 use List::MoreUtils qw(any lastval);
  4         18  
  4         43  
29              
30             # Base commands to run for various types of distributions. Additional
31             # variations may be added depending on additional configuration parameters.
32             #<<<
33             our %COMMANDS = (
34             'Autoconf' => [
35             ['./bootstrap'],
36             ['./configure', 'CC=clang'],
37             ['make', 'warnings'],
38             ['make', 'check'],
39             ['make', 'clean'],
40             ['./configure', 'CC=gcc'],
41             ['make', 'warnings'],
42             ['make', 'check'],
43             ['make', 'clean'],
44             ['make', 'check-cppcheck'],
45             ['make', 'distcheck'],
46             ],
47             'ExtUtils::MakeMaker' => [
48             ['perl', 'Makefile.PL'],
49             ['make', 'disttest'],
50             ['make', 'dist'],
51             ],
52             'Module::Build' => [
53             ['perl', 'Build.PL'],
54             ['./Build', 'disttest'],
55             ['./Build', 'dist'],
56             ],
57             'make' => [
58             ['make', 'dist'],
59             ],
60             );
61             #>>>
62              
63             # Regexes matching files or directories in the source tree to ignore when
64             # comparing it against the generated distribution (in other words, we don't
65             # care whether these files or any files in these directories are included in
66             # the distribution). These should match the full file path relative to the
67             # top directory.
68             #
69             # Include all of the build-generated files for docknot itself so that we can
70             # use the new version to release the new version.
71             ## no critic (RegularExpressions::ProhibitFixedStringMatches)
72             our @DIST_IGNORE = (
73             qr{ \A [.]git \z }xms,
74             qr{ \A autom4te[.]cache \z }xms,
75             qr{ \A Build \z }xms,
76             qr{ \A MANIFEST[.]bak \z }xms,
77             qr{ \A MYMETA [.] (?:json (?:[.]lock)? | yml) \z }xms,
78             qr{ \A _build \z }xms,
79             qr{ \A blib \z }xms,
80             qr{ \A config[.]h[.]in~ \z }xms,
81             qr{ \A cover_db \z }xms,
82             qr{ \A tests/config \z }xms,
83             qr{ [.]tar[.][gx]z \z }xms,
84             );
85             ## use critic
86              
87             ##############################################################################
88             # Helper methods
89             ##############################################################################
90              
91             # Given the path to the source tree, generate a list of files that we expect
92             # to find in the distribution tarball.
93             #
94             # $self - The App::DocKnot::Dist object
95             # $path - The directory path
96             #
97             # Returns: A list of files (no directories) that the distribution tarball
98             # should contain.
99             sub _expected_dist_files {
100 5     5   20 my ($self, $path) = @_;
101 5         12 my @files;
102              
103             # Find all files in the source directory, stripping its path from the file
104             # name and excluding (and pruning) anything matching @DIST_IGNORE.
105             my $wanted = sub {
106 86     86   185 my $name = $File::Find::name;
107 86         380 $name =~ s{ \A \Q$path\E / }{}xms;
108 86 50       172 return if !$name;
109 86 100       408 if (any { $name =~ $_ } @DIST_IGNORE) {
  896         1526  
110 5         9 $File::Find::prune = 1;
111 5         100 return;
112             }
113 81 100       2682 return if -d;
114 51         1029 push(@files, $name);
115 5         50 };
116              
117             # Generate and return the list of files.
118 5         560 find($wanted, $path);
119 5         40 return @files;
120             }
121              
122             # Find the tarball compressed with gzip given a directory and a prefix.
123             #
124             # $self - The App::DocKnot::Dist object
125             # $path - The directory path
126             # $prefix - The tarball file prefix
127             #
128             # Returns: The full path to the gzip tarball
129             # Throws: Text exception if no gzip tarball was found
130             sub _find_gzip_tarball {
131 3     3   14 my ($self, $path, $prefix) = @_;
132 3         19 my @files = $self->_find_matching_tarballs($path, $prefix);
133 3     3   55 my $gzip_file = lastval { m{ [.]tar [.]gz \z }xms } @files;
  3         37  
134 3 50       25 if (!defined($gzip_file)) {
135 0         0 die "cannot find gzip tarball for $prefix in $path\n";
136             }
137 3         57 return File::Spec->catfile($path, $gzip_file);
138             }
139              
140             # Find matching tarballs given a directory and a prefix.
141             #
142             # $self - The App::DocKnot::Dist object
143             # $path - The directory path
144             # $prefix - The tarball file prefix
145             #
146             # Returns: All matching files, without the directory name, as a list
147             sub _find_matching_tarballs {
148 9     9   36 my ($self, $path, $prefix) = @_;
149 9         229 my $pattern = qr{ \A \Q$prefix\E - \d.* [.]tar [.][xg]z \z }xms;
150 9         94 opendir(my $source, $path);
151 9         2561 my @files = grep { $_ =~ $pattern } readdir($source);
  68         255  
152 9         50 closedir($source);
153 9         997 return @files;
154             }
155              
156             # Given a directory and a prefix for tarballs in that directory, ensure that
157             # all the desired compression formats exist. Currently this only handles
158             # generating the xz version of a gzip tarball.
159             #
160             # $self - The App::DocKnot::Dist object
161             # $path - The directory path
162             # $prefix - The tarball file prefix
163             sub _generate_compression_formats {
164 3     3   12 my ($self, $path, $prefix) = @_;
165 3         14 my @files = $self->_find_matching_tarballs($path, $prefix);
166 3 100   3   49 if (!any { m{ [.]tar [.]xz \z }xms } @files) {
  3         36  
167 1     1   11 my $gzip_file = lastval { m{ [.]tar [.]gz \z }xms } @files;
  1         7  
168 1         28 systemx('gzip', '-dk', File::Spec->catfile($path, $gzip_file));
169 1         3783 my $tar_file = $gzip_file;
170 1         23 $tar_file =~ s{ [.]gz \z }{}xms;
171 1         28 systemx('xz', File::Spec->catfile($path, $tar_file));
172             }
173 3         31783 return;
174             }
175              
176             # Given a source directory, a prefix for tarballs and related files (such as
177             # signatures), and a destination directory, move all matching files from the
178             # source directory to the destination directory.
179             #
180             # $self - The App::DocKnot::Dist object
181             # $source_path - The source directory path
182             # $prefix - The tarball file prefix
183             # $dest_path - The destination directory path
184             #
185             # Throws: Text exception if no files are found
186             # Text exception on failure to move a file
187             sub _move_tarballs {
188 3     3   38 my ($self, $source_path, $prefix, $dest_path) = @_;
189 3         41 my @files = $self->_find_matching_tarballs($source_path, $prefix);
190 3         62 for my $file (@files) {
191 3         69 my $source_file = File::Spec->catfile($source_path, $file);
192 3 50       53 move($source_file, $dest_path)
193             or die "cannot move $source_file to $dest_path: $!\n";
194             }
195 3         1058 return;
196             }
197              
198             # Given a command with arguments, replace a command of "perl" with the
199             # configured path to Perl, if any. Assumes that the perl configuration
200             # parameter is set in the object and should not be called if this is not true.
201             #
202             # $self - The App::DocKnot::Dist object
203             # $command_ref - Reference to an array representing a command with arguments
204             #
205             # Returns: Reference to an array representing a command with arguments, with
206             # the command replaced with the configured path to Perl if it was
207             # "perl"
208             sub _replace_perl_path {
209 12     12   55 my ($self, $command_ref) = @_;
210 12 100       39 if ($command_ref->[0] ne 'perl') {
211 8         24 return $command_ref;
212             }
213 4         16 my @command = $command_ref->@*;
214 4         23 $command[0] = $self->{perl};
215 4         20 return [@command];
216             }
217              
218             ##############################################################################
219             # Public interface
220             ##############################################################################
221              
222             # Create a new App::DocKnot::Dist object, which will be used for subsequent
223             # calls.
224             #
225             # $class - Class of object ot create
226             # $args - Anonymous hash of arguments with the following keys:
227             # distdir - Path to the directory for distribution tarball
228             # metadata - Path to the directory containing package metadata
229             # perl - Path to Perl to use (default: search the user's PATH)
230             #
231             # Returns: Newly created object
232             # Throws: Text exceptions on invalid metadata directory path
233             # Text exception on missing or invalid distdir argument
234             sub new {
235 7     7 1 12214 my ($class, $args_ref) = @_;
236              
237             # Create the config reader.
238 7         14 my %config_args;
239 7 100       21 if ($args_ref->{metadata}) {
240 4         9 $config_args{metadata} = $args_ref->{metadata};
241             }
242 7         37 my $config = App::DocKnot::Config->new(\%config_args);
243              
244             # Ensure we were given a valid distdir argument.
245 7         14 my $distdir = $args_ref->{distdir};
246 7 50       74 if (!defined($distdir)) {
    50          
247 0         0 croak('distdir path not given');
248             } elsif (!-d $distdir) {
249 0         0 croak("distdir path $distdir does not exist or is not a directory");
250             }
251              
252             # Create and return the object.
253             my $self = {
254             config => $config->config(),
255             distdir => $distdir,
256             perl => $args_ref->{perl},
257 7         31 };
258 7         26 bless($self, $class);
259 7         90 return $self;
260             }
261              
262             # Given a distribution tarball compressed with gzip, ensure that every file
263             # from the source directory that is expected to be there is in the
264             # distribution tarball. Assumes that it is run from the root of the source
265             # directory.
266             #
267             # $self - The App::DocKnot::Dist object
268             # $source - Path to the source directory
269             # $tarball - Path to a gzip-compressed distribution tarball
270             #
271             # Returns: A list of files missing from the distribution (so an empty list
272             # means all expected files were found)
273             sub check_dist {
274 5     5 1 5426 my ($self, $source, $tarball) = @_;
275 5         57 my @expected = $self->_expected_dist_files(getcwd());
276 5         13 my %expected = map { $_ => 1 } @expected;
  51         154  
277 5         103 my $archive = Archive::Tar->new($tarball);
278 5         61114 for my $file ($archive->list_files()) {
279 85         1922 $file =~ s{ \A [^/]* / }{}xms;
280 85         136 delete $expected{$file};
281             }
282 5         36 my @missing = sort(keys(%expected));
283 5         157 return @missing;
284             }
285              
286             # Analyze a source directory and return the list of commands to run to
287             # generate a distribution tarball.
288             #
289             # $self - The App::DocKnot::Dist object
290             #
291             # Returns: List of commands, each of which is a list of strings representing
292             # a command and its arguments
293             sub commands {
294 9     9 1 100 my ($self) = @_;
295 9         47 my $type = $self->{config}{build}{type};
296 9         40 my @commands = map { [@$_] } $COMMANDS{$type}->@*;
  41         108  
297              
298             # Special-case: If a specific path to Perl was configured, use that path
299             # rather than searching for perl in the user's PATH. This is used
300             # primarily by the test suite, which wants to run a Module::Build Build.PL
301             # and thus has to use the same perl binary as the one running the tests.
302 9 100       38 if (defined($self->{perl})) {
303 4         11 @commands = map { $self->_replace_perl_path($_) } @commands;
  12         60  
304             }
305              
306             # Special-case: Autoconf packages with C++ support should also attempt a
307             # build with a C++ compiler.
308 9 100 100     31 if ($type eq 'Autoconf' && $self->{config}{build}{cplusplus}) {
309             #<<<
310 1         14 my @extra = (
311             ['./configure', 'CC=g++'],
312             ['make', 'check'],
313             ['make', 'clean'],
314             );
315             #>>>
316 1         3 splice(@commands, 1, 0, @extra);
317             }
318              
319             # Special-case: Autoconf packages with Valgrind support should also run
320             # make check-valgrind.
321 9 100 100     30 if ($type eq 'Autoconf' && $self->{config}{build}{valgrind}) {
322 1         11 splice(@commands, -3, 0, ['make', 'check-valgrind']);
323             }
324              
325 9         41 return @commands;
326             }
327              
328             # Generate a distribution tarball. This assumes it is run from the root
329             # directory of the package to release and that it is a Git repository. It
330             # exports the Git repository, runs the commands to generate the tarball, and
331             # then removes the working tree.
332             #
333             # $self - The App::DocKnot::Dist object
334             #
335             # Throws: Text exception if any of the commands fail
336             # Text exception if the distribution is missing files
337             sub make_distribution {
338 3     3 1 6969 my ($self) = @_;
339              
340             # Determine the source directory and the distribution directory name.
341 3 50       33 my $source = getcwd() or die "cannot get current directory: $!\n";
342 3         96 my $prefix = $self->{config}{distribution}{tarname};
343              
344             # If the distribution directory name already exists, remove it. Automake
345             # may have made parts of it read-only, so be forceful in the removal.
346             # Note that this does not pass the safe parameter and therefore should not
347             # be called on attacker-controlled directories.
348 3         17 chdir($self->{distdir});
349 3 100       203 if (-d $prefix) {
350 1         422 remove_tree($prefix);
351             }
352              
353             # Export the Git repository into a new directory.
354 3         47 my @git = ('git', 'archive', "--remote=$source", "--prefix=${prefix}/",
355             'master',);
356 3         10 my @tar = qw(tar xf -);
357 3 50       37 run(\@git, q{|}, \@tar) or die "@git | @tar failed with status $?\n";
358              
359             # Change to that directory and run the configured commands.
360 3         75895 chdir($prefix);
361 3         618 for my $command_ref ($self->commands()) {
362 9         3985504 systemx($command_ref->@*);
363             }
364              
365             # Move the generated tarball to the parent directory.
366 3         802047 $self->_move_tarballs(File::Spec->curdir(), $prefix, File::Spec->updir());
367              
368             # Remove the working tree.
369 3         31 chdir(File::Spec->updir());
370 3         8070 remove_tree($prefix, { safe => 1 });
371              
372             # Generate additional compression formats if needed.
373 3         58 $self->_generate_compression_formats(getcwd(), $prefix);
374              
375             # Check the distribution for any missing files. If there are any, report
376             # them and then fail with an error.
377 3         62 my $tarball = $self->_find_gzip_tarball(getcwd(), $prefix);
378 3         17 chdir($source);
379 3         207 my @missing = $self->check_dist($source, $tarball);
380 3 100       32 if (@missing) {
381 2 50       76 print "Files found in local tree but not in distribution:\n"
382             or die "cannot print to stdout: $!\n";
383 2 50       34 print q{ } . join(qq{\n }, @missing) . "\n"
384             or die "cannot print to stdout: $!\n";
385 2         12 my $count = scalar(@missing);
386 2 100       19 my $files = ($count == 1) ? '1 file' : "$count files";
387 2         52 die "$files missing from distribution\n";
388             }
389 1         24 return;
390             }
391              
392             ##############################################################################
393             # Module return value and documentation
394             ##############################################################################
395              
396             1;
397             __END__